Add music player: Google Play Music Desktop Player.

The code for GPMDP is a lot more complicated than the code for other
music players. See BGMGooglePlayMusicDesktopPlayer.h for details.

Adds a class, BGMAppWatcher, to hold the code that notifies listeners
when a given app is launched or terminated.

Resolves #161.
This commit is contained in:
Kyle Neideck 2019-06-09 10:46:08 +10:00
parent 503d1a92ec
commit e616718eab
No known key found for this signature in database
GPG key ID: CAA8D9B8E39EC18C
24 changed files with 1548 additions and 164 deletions

View file

@ -8,11 +8,11 @@
/* Begin PBXBuildFile section */
19FE7071FF5280BC38F35E1D /* BGMVolumeChangeListener.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 19FE7179EBFA116F3861E79D /* BGMVolumeChangeListener.cpp */; };
19FE719951725A698A419CBA /* BGMVolumeChangeListener.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 19FE7179EBFA116F3861E79D /* BGMVolumeChangeListener.cpp */; };
19FE719951725A698A419CBA /* BGMVolumeChangeListener.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 19FE7179EBFA116F3861E79D /* BGMVolumeChangeListener.cpp */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMVolumeChangeListener.cpp"; }; };
19FE77608F6C80D0B1F595A7 /* BGMStatusBarItem.mm in Sources */ = {isa = PBXBuildFile; fileRef = 19FE774DD758EC163EF4F28C /* BGMStatusBarItem.mm */; };
19FE7921FD1B6C037429ECA4 /* BGMStatusBarItem.mm in Sources */ = {isa = PBXBuildFile; fileRef = 19FE774DD758EC163EF4F28C /* BGMStatusBarItem.mm */; };
19FE7DFF63F69E77C53BF95E /* BGMVolumeChangeListener.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 19FE7179EBFA116F3861E79D /* BGMVolumeChangeListener.cpp */; };
19FE7F77376562C179449013 /* BGMStatusBarItem.mm in Sources */ = {isa = PBXBuildFile; fileRef = 19FE774DD758EC163EF4F28C /* BGMStatusBarItem.mm */; };
19FE7F77376562C179449013 /* BGMStatusBarItem.mm in Sources */ = {isa = PBXBuildFile; fileRef = 19FE774DD758EC163EF4F28C /* BGMStatusBarItem.mm */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMStatusBarItem.mm"; }; };
1C0BD0A51BF1A8E6004F4CF5 /* BGMAutoPauseMusicPrefs.mm in Sources */ = {isa = PBXBuildFile; fileRef = 1C0BD0A41BF1A8E6004F4CF5 /* BGMAutoPauseMusicPrefs.mm */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMAutoPauseMusicPrefs.mm"; }; };
1C0BD0A81BF1B029004F4CF5 /* BGMPreferencesMenu.mm in Sources */ = {isa = PBXBuildFile; fileRef = 1C0BD0A71BF1B029004F4CF5 /* BGMPreferencesMenu.mm */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMPreferencesMenu.mm"; }; };
1C1465B81BCC3A73003AEFE6 /* BGMAutoPauseMusic.mm in Sources */ = {isa = PBXBuildFile; fileRef = 1C1465B71BCC3A73003AEFE6 /* BGMAutoPauseMusic.mm */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMAutoPauseMusic.mm"; }; };
@ -55,6 +55,9 @@
1C533C801EF532CA00270802 /* _uninstall-non-interactive.sh in Resources */ = {isa = PBXBuildFile; fileRef = 1C533C7F1EF532CA00270802 /* _uninstall-non-interactive.sh */; };
1C780FF21FEF6C3B00497FAD /* BGMSystemSoundsVolume.mm in Sources */ = {isa = PBXBuildFile; fileRef = 1C780FF11FEF6C3B00497FAD /* BGMSystemSoundsVolume.mm */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMSystemSoundsVolume.mm"; }; };
1C780FF31FEF6C3B00497FAD /* BGMSystemSoundsVolume.mm in Sources */ = {isa = PBXBuildFile; fileRef = 1C780FF11FEF6C3B00497FAD /* BGMSystemSoundsVolume.mm */; };
1C8034D520B0347A004BC50C /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1C8034D420B0347A004BC50C /* Security.framework */; };
1C80DED320A6718600045BBE /* BGMAppWatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C80DED220A6718600045BBE /* BGMAppWatcher.m */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMAppWatcher.m"; }; };
1C80DED420A6718600045BBE /* BGMAppWatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C80DED220A6718600045BBE /* BGMAppWatcher.m */; };
1C837DD81F6AA1F2004B1E60 /* BGMOutputVolumeMenuItem.mm in Sources */ = {isa = PBXBuildFile; fileRef = 1C837DD71F6AA1F2004B1E60 /* BGMOutputVolumeMenuItem.mm */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMOutputVolumeMenuItem.mm"; }; };
1C837DD91F6AA1F2004B1E60 /* BGMOutputVolumeMenuItem.mm in Sources */ = {isa = PBXBuildFile; fileRef = 1C837DD71F6AA1F2004B1E60 /* BGMOutputVolumeMenuItem.mm */; };
1C837DDA1F6AA1F2004B1E60 /* BGMOutputVolumeMenuItem.mm in Sources */ = {isa = PBXBuildFile; fileRef = 1C837DD71F6AA1F2004B1E60 /* BGMOutputVolumeMenuItem.mm */; };
@ -64,6 +67,13 @@
1C8D8304204238DB00A838F2 /* BGMSwinsian.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C8D8302204238DB00A838F2 /* BGMSwinsian.m */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMSwinsian.m"; }; };
1C8D830520423E1C00A838F2 /* BGMSwinsian.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C8D8302204238DB00A838F2 /* BGMSwinsian.m */; };
1C8D830620423E2400A838F2 /* BGMSwinsian.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C8D8302204238DB00A838F2 /* BGMSwinsian.m */; };
1C8D830B2042DE9600A838F2 /* BGMGooglePlayMusicDesktopPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C8D830A2042DE9500A838F2 /* BGMGooglePlayMusicDesktopPlayer.m */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMGooglePlayMusicDesktopPlayer.m"; }; };
1C8D830C2042DE9600A838F2 /* BGMGooglePlayMusicDesktopPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C8D830A2042DE9500A838F2 /* BGMGooglePlayMusicDesktopPlayer.m */; };
1C8D830E2042F25C00A838F2 /* GooglePlayMusicDesktopPlayer.js in Resources */ = {isa = PBXBuildFile; fileRef = 1C8D830D2042F25C00A838F2 /* GooglePlayMusicDesktopPlayer.js */; };
1C8D830F2042F25C00A838F2 /* GooglePlayMusicDesktopPlayer.js in Resources */ = {isa = PBXBuildFile; fileRef = 1C8D830D2042F25C00A838F2 /* GooglePlayMusicDesktopPlayer.js */; };
1C9258472090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C9258462090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.m */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMGooglePlayMusicDesktopPlayerConnection.m"; }; };
1C9258482090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C9258462090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.m */; };
1C9258492090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C9258462090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.m */; };
1CACCF391F3175AD007F86CA /* BGMBackgroundMusicDevice.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 1CACCF371F3175AD007F86CA /* BGMBackgroundMusicDevice.cpp */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMBackgroundMusicDevice.cpp"; }; };
1CACCF3A1F334447007F86CA /* BGMBackgroundMusicDevice.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 1CACCF371F3175AD007F86CA /* BGMBackgroundMusicDevice.cpp */; };
1CACCF3B1F334450007F86CA /* BGMBackgroundMusicDevice.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 1CACCF371F3175AD007F86CA /* BGMBackgroundMusicDevice.cpp */; };
@ -276,12 +286,20 @@
1C780FF11FEF6C3B00497FAD /* BGMSystemSoundsVolume.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = BGMSystemSoundsVolume.mm; sourceTree = "<group>"; };
1C8034C21BDAFD5700668E00 /* CAPThread.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; name = CAPThread.cpp; path = PublicUtility/CAPThread.cpp; sourceTree = "<group>"; };
1C8034C31BDAFD5700668E00 /* CAPThread.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = CAPThread.h; path = PublicUtility/CAPThread.h; sourceTree = "<group>"; };
1C8034D420B0347A004BC50C /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
1C80DED120A6718600045BBE /* BGMAppWatcher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BGMAppWatcher.h; sourceTree = "<group>"; };
1C80DED220A6718600045BBE /* BGMAppWatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BGMAppWatcher.m; sourceTree = "<group>"; };
1C837DD61F6AA1F2004B1E60 /* BGMOutputVolumeMenuItem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BGMOutputVolumeMenuItem.h; sourceTree = "<group>"; };
1C837DD71F6AA1F2004B1E60 /* BGMOutputVolumeMenuItem.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = BGMOutputVolumeMenuItem.mm; sourceTree = "<group>"; };
1C8B0C69216205BF008C5679 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; };
1C8D8301204238DB00A838F2 /* Swinsian.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Swinsian.h; path = "Music Players/Swinsian.h"; sourceTree = "<group>"; };
1C8D8302204238DB00A838F2 /* BGMSwinsian.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = BGMSwinsian.m; path = "Music Players/BGMSwinsian.m"; sourceTree = "<group>"; };
1C8D8303204238DB00A838F2 /* BGMSwinsian.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = BGMSwinsian.h; path = "Music Players/BGMSwinsian.h"; sourceTree = "<group>"; };
1C8D83092042DE9500A838F2 /* BGMGooglePlayMusicDesktopPlayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = BGMGooglePlayMusicDesktopPlayer.h; path = "Music Players/BGMGooglePlayMusicDesktopPlayer.h"; sourceTree = "<group>"; };
1C8D830A2042DE9500A838F2 /* BGMGooglePlayMusicDesktopPlayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = BGMGooglePlayMusicDesktopPlayer.m; path = "Music Players/BGMGooglePlayMusicDesktopPlayer.m"; sourceTree = "<group>"; };
1C8D830D2042F25C00A838F2 /* GooglePlayMusicDesktopPlayer.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = GooglePlayMusicDesktopPlayer.js; path = "Music Players/GooglePlayMusicDesktopPlayer.js"; sourceTree = "<group>"; };
1C9258452090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = BGMGooglePlayMusicDesktopPlayerConnection.h; path = "Music Players/BGMGooglePlayMusicDesktopPlayerConnection.h"; sourceTree = "<group>"; };
1C9258462090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = BGMGooglePlayMusicDesktopPlayerConnection.m; path = "Music Players/BGMGooglePlayMusicDesktopPlayerConnection.m"; sourceTree = "<group>"; };
1CACCF371F3175AD007F86CA /* BGMBackgroundMusicDevice.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = BGMBackgroundMusicDevice.cpp; sourceTree = "<group>"; };
1CACCF381F3175AD007F86CA /* BGMBackgroundMusicDevice.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BGMBackgroundMusicDevice.h; sourceTree = "<group>"; };
1CB8B3361BBA75EF000E2DD1 /* Background Music.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Background Music.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@ -376,6 +394,7 @@
buildActionMask = 2147483647;
files = (
1C8B0C6A216205BF008C5679 /* AVFoundation.framework in Frameworks */,
1C8034D520B0347A004BC50C /* Security.framework in Frameworks */,
270A84511E0044EF00F13C99 /* ScriptingBridge.framework in Frameworks */,
1CD1FD301BDDEAF2004F7E1B /* AudioToolbox.framework in Frameworks */,
1C1963031BCAC160008A4DF7 /* CoreAudio.framework in Frameworks */,
@ -510,6 +529,10 @@
1C2336D91BEAB6E7004C1C4E /* BGMMusicPlayer.m */,
27F7D48E1D2483B100821C4B /* BGMDecibel.h */,
27F7D48F1D2483B100821C4B /* BGMDecibel.m */,
1C8D83092042DE9500A838F2 /* BGMGooglePlayMusicDesktopPlayer.h */,
1C8D830A2042DE9500A838F2 /* BGMGooglePlayMusicDesktopPlayer.m */,
1C9258452090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.h */,
1C9258462090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.m */,
1C2336DB1BEAB73F004C1C4E /* BGMiTunes.h */,
1C4699461BD5C0E400F78043 /* BGMiTunes.m */,
1C2336DD1BEAE10C004C1C4E /* BGMSpotify.h */,
@ -559,6 +582,8 @@
children = (
1CB8B33B1BBA75EF000E2DD1 /* BGMAppDelegate.h */,
1CB8B33C1BBA75EF000E2DD1 /* BGMAppDelegate.mm */,
1C80DED120A6718600045BBE /* BGMAppWatcher.h */,
1C80DED220A6718600045BBE /* BGMAppWatcher.m */,
1C837DD61F6AA1F2004B1E60 /* BGMOutputVolumeMenuItem.h */,
1C837DD71F6AA1F2004B1E60 /* BGMOutputVolumeMenuItem.mm */,
1C780FF01FEF6C3B00497FAD /* BGMSystemSoundsVolume.h */,
@ -692,6 +717,7 @@
27F7D4911D2484A300821C4B /* Decibel.h */,
279F48781DD6D94000768A85 /* Hermes.h */,
27379B851C7C54870084A24C /* iTunes.h */,
1C8D830D2042F25C00A838F2 /* GooglePlayMusicDesktopPlayer.js */,
27379B861C7C54870084A24C /* Spotify.h */,
1C8D8301204238DB00A838F2 /* Swinsian.h */,
27379B871C7C552A0084A24C /* VLC.h */,
@ -717,6 +743,7 @@
2743CA1B1D86DA9B0089613B /* Frameworks */ = {
isa = PBXGroup;
children = (
1C8034D420B0347A004BC50C /* Security.framework */,
1C8B0C69216205BF008C5679 /* AVFoundation.framework */,
270A84501E0044EE00F13C99 /* ScriptingBridge.framework */,
1CD1FD2F1BDDEAF2004F7E1B /* AudioToolbox.framework */,
@ -871,6 +898,7 @@
developmentRegion = English;
hasScannedForEncodings = 0;
knownRegions = (
English,
en,
Base,
);
@ -895,6 +923,7 @@
files = (
274827951E11052500B31D8D /* MainMenu.xib in Resources */,
1C533C7A1EED28B700270802 /* uninstall.sh in Resources */,
1C8D830E2042F25C00A838F2 /* GooglePlayMusicDesktopPlayer.js in Resources */,
1C533C801EF532CA00270802 /* _uninstall-non-interactive.sh in Resources */,
1CED61691C3081C2002CAFCF /* LICENSE in Resources */,
1C2FC3041EB4D6E700A76592 /* BGMApp.sdef in Resources */,
@ -922,6 +951,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1C8D830F2042F25C00A838F2 /* GooglePlayMusicDesktopPlayer.js in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -973,6 +1003,7 @@
1C4699471BD5C0E400F78043 /* BGMiTunes.m in Sources */,
1CD410D41F9EDDAD0070A094 /* BGMAppVolumesController.mm in Sources */,
1C1962E41BC94E15008A4DF7 /* CARingBuffer.cpp in Sources */,
1C8D830B2042DE9600A838F2 /* BGMGooglePlayMusicDesktopPlayer.m in Sources */,
273F10DF1CC3D0B900C1C6DA /* BGMVOX.m in Sources */,
1CC1DF811BE5068A00FB8FE4 /* CACFArray.cpp in Sources */,
279F48771DD6D73A00768A85 /* BGMHermes.m in Sources */,
@ -1003,9 +1034,11 @@
1CED616C1C316E1A002CAFCF /* BGMAudioDeviceManager.mm in Sources */,
2743C9F11D853FBB0089613B /* BGMUserDefaults.m in Sources */,
1C1962FD1BCAC0C3008A4DF7 /* CADebugPrintf.cpp in Sources */,
1C80DED320A6718600045BBE /* BGMAppWatcher.m in Sources */,
2743C9EC1D852B360089613B /* BGMScriptingBridge.m in Sources */,
1CC6593C1F91DEB400B0CCDC /* BGMTermination.mm in Sources */,
1C837DD81F6AA1F2004B1E60 /* BGMOutputVolumeMenuItem.mm in Sources */,
1C9258472090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.m in Sources */,
1C1963011BCAC0F6008A4DF7 /* CACFString.cpp in Sources */,
1C1962E71BC94E91008A4DF7 /* BGMPlayThrough.cpp in Sources */,
1C8D8304204238DB00A838F2 /* BGMSwinsian.m in Sources */,
@ -1031,6 +1064,7 @@
1CACCF3A1F334447007F86CA /* BGMBackgroundMusicDevice.cpp in Sources */,
1C780FF31FEF6C3B00497FAD /* BGMSystemSoundsVolume.mm in Sources */,
1CC6593D1F91DEB400B0CCDC /* BGMTermination.mm in Sources */,
1C80DED420A6718600045BBE /* BGMAppWatcher.m in Sources */,
1CD410D51F9EDDAD0070A094 /* BGMAppVolumesController.mm in Sources */,
1CD989571ECFFD250014BBBF /* CAHostTimeBase.cpp in Sources */,
1CD989581ECFFD250014BBBF /* CAMutex.cpp in Sources */,
@ -1047,6 +1081,7 @@
1CD989481ECFFCFC0014BBBF /* BGMMusicPlayer.m in Sources */,
1CD989491ECFFCFC0014BBBF /* BGMDecibel.m in Sources */,
1C3D36731ED90E8600F98E66 /* BGMDeviceControlsList.cpp in Sources */,
1C9258482090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.m in Sources */,
1CD9894A1ECFFCFC0014BBBF /* BGMiTunes.m in Sources */,
1CD9894B1ECFFCFC0014BBBF /* BGMSpotify.m in Sources */,
1CD9894C1ECFFCFC0014BBBF /* BGMHermes.m in Sources */,
@ -1118,6 +1153,7 @@
1C227C0B1FA4C48200A95B6D /* BGMAppVolumes.m in Sources */,
1CEACF4D1F34793700FEC143 /* CAHALAudioDevice.cpp in Sources */,
1CACCF3B1F334450007F86CA /* BGMBackgroundMusicDevice.cpp in Sources */,
1C8D830C2042DE9600A838F2 /* BGMGooglePlayMusicDesktopPlayer.m in Sources */,
1C3D36741ED90E8600F98E66 /* BGMDeviceControlsList.cpp in Sources */,
27FB8C301DE4758A0084DB9D /* BGMPlayThrough.cpp in Sources */,
27FB8C311DE4758A0084DB9D /* BGM_Utils.cpp in Sources */,
@ -1154,6 +1190,7 @@
2743CA021D86D3CB0089613B /* BGMiTunes.m in Sources */,
19FE77608F6C80D0B1F595A7 /* BGMStatusBarItem.mm in Sources */,
19FE7071FF5280BC38F35E1D /* BGMVolumeChangeListener.cpp in Sources */,
1C9258492090287F00B8D3A6 /* BGMGooglePlayMusicDesktopPlayerConnection.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -0,0 +1,49 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAppWatcher.h
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Calls callback functions when a given application is launched or terminated. Starts watching
// after being initialised, stops after being destroyed.
//
// System Includes
#import <Foundation/Foundation.h>
#pragma clang assume_nonnull begin
@interface BGMAppWatcher : NSObject
// appLaunched will be called when the application is launched and appTerminated will be called when
// it's terminated. Background apps, status bar apps, etc. are ignored.
- (instancetype) initWithBundleID:(NSString*)bundleID
appLaunched:(void(^)(void))appLaunched
appTerminated:(void(^)(void))appTerminated;
// With this constructor, when an application is launched or terminated, isMatchingBundleID will be
// called first to decide whether or not the callback should be called.
- (instancetype) initWithAppLaunched:(void(^)(void))appLaunched
appTerminated:(void(^)(void))appTerminated
isMatchingBundleID:(BOOL(^)(NSString* appBundleID))isMatchingBundleID;
@end
#pragma clang assume_nonnull end

View file

@ -0,0 +1,109 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAppWatcher.m
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Self Include
#import "BGMAppWatcher.h"
// System Includes
#import <Cocoa/Cocoa.h>
#pragma clang assume_nonnull begin
@implementation BGMAppWatcher {
// Tokens for the notification observers so we can remove them in dealloc.
id<NSObject> didLaunchToken;
id<NSObject> didTerminateToken;
}
- (instancetype) initWithBundleID:(NSString*)bundleID
appLaunched:(void(^)(void))appLaunched
appTerminated:(void(^)(void))appTerminated {
return [self initWithAppLaunched:appLaunched
appTerminated:appTerminated
isMatchingBundleID:^BOOL(NSString* appBundleID) {
return [bundleID isEqualToString:appBundleID];
}];
}
- (instancetype) initWithAppLaunched:(void(^)(void))appLaunched
appTerminated:(void(^)(void))appTerminated
isMatchingBundleID:(BOOL(^)(NSString*))isMatchingBundleID
{
if ((self = [super init])) {
NSNotificationCenter* center = [NSWorkspace sharedWorkspace].notificationCenter;
didLaunchToken =
[center addObserverForName:NSWorkspaceDidLaunchApplicationNotification
object:nil
queue:nil
usingBlock:^(NSNotification* notification) {
if ([BGMAppWatcher shouldBeHandled:notification
isMatchingBundleID:isMatchingBundleID]) {
appLaunched();
}
}];
didTerminateToken =
[center addObserverForName:NSWorkspaceDidTerminateApplicationNotification
object:nil
queue:nil
usingBlock:^(NSNotification* notification) {
if ([BGMAppWatcher shouldBeHandled:notification
isMatchingBundleID:isMatchingBundleID]) {
appTerminated();
}
}];
}
return self;
}
// Returns YES if we should call the app launch/termination callback for this NSNotification.
+ (BOOL) shouldBeHandled:(NSNotification*)notification
isMatchingBundleID:(BOOL(^)(NSString*))isMatchingBundleID {
NSString* __nullable notifiedBundleID =
[notification.userInfo[NSWorkspaceApplicationKey] bundleIdentifier];
// Ignore the notification if the app doesn't have a bundle ID or isMatchingBundleID returns NO.
return notifiedBundleID && isMatchingBundleID((NSString*)notifiedBundleID);
}
- (void) dealloc {
// Remove the application launch/termination observers.
NSNotificationCenter* center = [NSWorkspace sharedWorkspace].notificationCenter;
if (didLaunchToken) {
[center removeObserver:didLaunchToken];
didLaunchToken = nil;
}
if (didTerminateToken) {
[center removeObserver:didTerminateToken];
didTerminateToken = nil;
}
}
@end
#pragma clang assume_nonnull end

View file

@ -17,7 +17,7 @@
// BGMAutoPauseMenuItem.m
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016, 2019 Kyle Neideck
// Copyright © 2016 Tanner Hoke
//
@ -25,7 +25,7 @@
#import "BGMAutoPauseMenuItem.h"
// Local Includes
#import "BGMMusicPlayer.h"
#import "BGMAppWatcher.h"
#pragma clang assume_nonnull begin
@ -41,7 +41,7 @@ static SInt64 const kMenuItemUpdateWaitTime = 1;
NSMenuItem* menuItem;
BGMAutoPauseMusic* autoPauseMusic;
BGMMusicPlayers* musicPlayers;
id<NSObject> didLaunchToken, didTerminateToken;
BGMAppWatcher* appWatcher;
}
- (instancetype) initWithMenuItem:(NSMenuItem*)item
@ -66,53 +66,39 @@ static SInt64 const kMenuItemUpdateWaitTime = 1;
// Toggle auto-pause when the menu item is clicked.
menuItem.target = self;
menuItem.action = @selector(toggleAutoPauseMusic);
[self updateMenuItemTitle];
[self initMusicPlayerObservers];
[self initMenuItemTitle];
}
return self;
}
- (void) initMusicPlayerObservers {
// Add observers that enable/disable the Auto-pause Music menu item when the music player is launched/terminated.
NSNotificationCenter* center = [[NSWorkspace sharedWorkspace] notificationCenter];
id<NSObject> (^addObserver)(NSString*) = ^(NSString* name) {
return [center addObserverForName:name
object:nil
queue:nil
usingBlock:^(NSNotification* note) {
NSString* appBundleID = [note.userInfo[NSWorkspaceApplicationKey] bundleIdentifier];
BOOL isAboutThisMusicPlayer = musicPlayers.selectedMusicPlayer.bundleID &&
[appBundleID isEqualToString:(NSString*)musicPlayers.selectedMusicPlayer.bundleID];
if (isAboutThisMusicPlayer) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
kMenuItemUpdateWaitTime * NSEC_PER_SEC),
dispatch_get_main_queue(),
^{
[self updateMenuItemTitle];
});
}
}];
};
didLaunchToken = addObserver(NSWorkspaceDidLaunchApplicationNotification);
didTerminateToken = addObserver(NSWorkspaceDidTerminateApplicationNotification);
}
- (void) initMenuItemTitle {
// Set the initial text, tool-tip, state, etc.
[self updateMenuItemTitle];
- (void) dealloc {
// Remove the application launch/termination observers.
NSNotificationCenter* center = [[NSWorkspace sharedWorkspace] notificationCenter];
if (didLaunchToken) {
[center removeObserver:didLaunchToken];
}
if (didTerminateToken) {
[center removeObserver:didTerminateToken];
}
// Avoid retain cycles in case we ever want to destroy instances of this class.
BGMAutoPauseMenuItem* __weak weakSelf = self;
// Add a callback that enables/disables the Auto-pause Music menu item when the music player
// is launched/terminated.
void (^callback)(void) = ^{
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, kMenuItemUpdateWaitTime * NSEC_PER_SEC),
dispatch_get_main_queue(),
^{
BGMAutoPauseMenuItem* strongSelf = weakSelf;
[strongSelf updateMenuItemTitle];
});
};
appWatcher = [[BGMAppWatcher alloc] initWithAppLaunched:callback
appTerminated:callback
isMatchingBundleID:^BOOL(NSString* appBundleID) {
BGMAutoPauseMenuItem* strongSelf = weakSelf;
NSString* __nullable playerBundleID =
strongSelf->musicPlayers.selectedMusicPlayer.bundleID;
return playerBundleID && [appBundleID isEqualToString:(NSString*)playerBundleID];
}];
}
- (void) toggleAutoPauseMusic {
@ -143,7 +129,7 @@ static SInt64 const kMenuItemUpdateWaitTime = 1;
//
// We don't actually disable it just in case the user decides to disable auto-pause and their music player isn't
// running. E.g. someone who only recently installed Background Music and doesn't want to use auto-pause at all.
if (musicPlayers.selectedMusicPlayer.isRunning) {
if (musicPlayers.selectedMusicPlayer.running) {
menuItem.attributedTitle = nil;
menuItem.toolTip = nil;
} else {

View file

@ -22,6 +22,8 @@
// A simple wrapper around our use of NSUserDefaults. Used to store the preferences/state that only
// apply to BGMApp. The others are stored by BGMDriver.
//
// Private data will be stored in the user's keychain instead of user defaults.
//
// Local Includes
#import "BGMStatusBarItem.h"
@ -51,6 +53,13 @@
// BGMApp's main menu.)
@property BGMStatusBarIcon statusBarIcon;
// The auth code we're required to send when connecting to GPMDP. Stored in the keychain. Reading
// this property is thread-safe, but writing it isn't.
//
// Returns nil if no code is found or if reading fails. If writing fails, an error is logged, but no
// exception is thrown.
@property NSString* __nullable googlePlayMusicDesktopPlayerPermanentAuthCode;
@end
#pragma clang assume_nonnull end

View file

@ -30,10 +30,14 @@
#pragma clang assume_nonnull begin
// Keys
static NSString* const BGMDefaults_AutoPauseMusicEnabled = @"AutoPauseMusicEnabled";
static NSString* const BGMDefaults_SelectedMusicPlayerID = @"SelectedMusicPlayerID";
static NSString* const BGMDefaults_PreferredDeviceUIDs = @"PreferredDeviceUIDs";
static NSString* const BGMDefaults_StatusBarIcon = @"StatusBarIcon";
static NSString* const kDefaultKeyAutoPauseMusicEnabled = @"AutoPauseMusicEnabled";
static NSString* const kDefaultKeySelectedMusicPlayerID = @"SelectedMusicPlayerID";
static NSString* const kDefaultKeyPreferredDeviceUIDs = @"PreferredDeviceUIDs";
static NSString* const kDefaultKeyStatusBarIcon = @"StatusBarIcon";
// Labels for Keychain Data
static NSString* const kKeychainLabelGPMDPAuthCode =
@"app.backgroundmusic: Google Play Music Desktop Player permanent auth code";
@implementation BGMUserDefaults {
// The defaults object wrapped by this object.
@ -49,11 +53,11 @@ static NSString* const BGMDefaults_StatusBarIcon = @"StatusBarIcon";
// Register the settings defaults.
//
// iTunes is the default music player, but we don't set BGMDefaults_SelectedMusicPlayerID
// iTunes is the default music player, but we don't set kDefaultKeySelectedMusicPlayerID
// here so we know when it's never been set. (If it hasn't, we try using BGMDevice's
// kAudioDeviceCustomPropertyMusicPlayerBundleID property to tell which music player should
// be selected. See BGMMusicPlayers.)
NSDictionary* defaultsDict = @{ BGMDefaults_AutoPauseMusicEnabled: @YES };
NSDictionary* defaultsDict = @{ kDefaultKeyAutoPauseMusicEnabled: @YES };
if (defaults) {
[defaults registerDefaults:defaultsDict];
@ -65,33 +69,37 @@ static NSString* const BGMDefaults_StatusBarIcon = @"StatusBarIcon";
return self;
}
#pragma mark Selected Music Player
- (NSString* __nullable) selectedMusicPlayerID {
return [self get:BGMDefaults_SelectedMusicPlayerID];
return [self get:kDefaultKeySelectedMusicPlayerID];
}
- (void) setSelectedMusicPlayerID:(NSString* __nullable)selectedMusicPlayerID {
[self set:BGMDefaults_SelectedMusicPlayerID to:selectedMusicPlayerID];
[self set:kDefaultKeySelectedMusicPlayerID to:selectedMusicPlayerID];
}
#pragma mark Auto-pause
- (BOOL) autoPauseMusicEnabled {
return [self getBool:BGMDefaults_AutoPauseMusicEnabled];
return [self getBool:kDefaultKeyAutoPauseMusicEnabled];
}
- (void) setAutoPauseMusicEnabled:(BOOL)autoPauseMusicEnabled {
[self setBool:BGMDefaults_AutoPauseMusicEnabled to:autoPauseMusicEnabled];
[self setBool:kDefaultKeyAutoPauseMusicEnabled to:autoPauseMusicEnabled];
}
- (NSArray<NSString*>*) preferredDeviceUIDs {
NSArray<NSString*>* __nullable uids = [self get:BGMDefaults_PreferredDeviceUIDs];
NSArray<NSString*>* __nullable uids = [self get:kDefaultKeyPreferredDeviceUIDs];
return uids ? BGMNN(uids) : @[];
}
- (void) setPreferredDeviceUIDs:(NSArray<NSString*>*)devices {
[self set:BGMDefaults_PreferredDeviceUIDs to:devices];
[self set:kDefaultKeyPreferredDeviceUIDs to:devices];
}
- (BGMStatusBarIcon) statusBarIcon {
NSInteger icon = [self getInt:BGMDefaults_StatusBarIcon or:kBGMStatusBarIconDefaultValue];
NSInteger icon = [self getInt:kDefaultKeyStatusBarIcon or:kBGMStatusBarIconDefaultValue];
// Just in case we get an invalid value somehow.
if ((icon < kBGMStatusBarIconMinValue) || (icon > kBGMStatusBarIconMaxValue)) {
@ -103,10 +111,96 @@ static NSString* const BGMDefaults_StatusBarIcon = @"StatusBarIcon";
}
- (void) setStatusBarIcon:(BGMStatusBarIcon)icon {
[self setInt:BGMDefaults_StatusBarIcon to:icon];
[self setInt:kDefaultKeyStatusBarIcon to:icon];
}
#pragma mark Implementation
#pragma mark Google Play Music Desktop Player
- (NSString* __nullable) googlePlayMusicDesktopPlayerPermanentAuthCode {
// Try to read the permanent auth code from the user's keychain.
NSDictionary<NSString*, NSObject*>* query = @{
(__bridge NSString*)kSecClass: (__bridge NSString*)kSecClassGenericPassword,
(__bridge NSString*)kSecAttrLabel: kKeychainLabelGPMDPAuthCode,
(__bridge NSString*)kSecMatchLimit: (__bridge NSString*)kSecMatchLimitOne,
(__bridge NSString*)kSecReturnData: @YES
};
CFTypeRef result = nil;
OSStatus err = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
NSString* __nullable authCode = nil;
// Check the return status, null check and check the type.
if ((err == errSecSuccess) && result && (CFGetTypeID(result) == CFDataGetTypeID())) {
// Convert it to a string.
CFStringRef __nullable code =
CFStringCreateFromExternalRepresentation(kCFAllocatorDefault,
result,
kCFStringEncodingUTF8);
authCode = (__bridge_transfer NSString* __nullable)code;
} else if (err != errSecItemNotFound) {
NSString* __nullable errMsg =
(__bridge_transfer NSString* __nullable)SecCopyErrorMessageString(err, nil);
NSLog(@"Failed to read GPMDP auth code from keychain: %d, %@", err, errMsg);
}
// Release the data we read.
if (result) {
CFRelease(result);
}
return authCode;
}
- (void) setGooglePlayMusicDesktopPlayerPermanentAuthCode:(NSString* __nullable)authCode {
if (authCode) {
// Convert it to an NSData so we can store it in the user's keychain.
NSData* authCodeData = [authCode dataUsingEncoding:NSUTF8StringEncoding];
// Delete the old code if necessary. (There's an update function, but this takes less code.)
if (self.googlePlayMusicDesktopPlayerPermanentAuthCode) {
[self deleteGPMDPPermanentAuthCode];
}
// Store the code.
[self addGPMDPPermanentAuthCode:authCodeData];
} else {
[self deleteGPMDPPermanentAuthCode];
}
}
- (void) addGPMDPPermanentAuthCode:(NSData*)authCodeData {
NSDictionary<NSString*, NSObject*>* attributes = @{
(__bridge NSString*)kSecClass: (__bridge NSString*)kSecClassGenericPassword,
(__bridge NSString*)kSecAttrLabel: kKeychainLabelGPMDPAuthCode,
(__bridge NSString*)kSecValueData: authCodeData
};
OSStatus err = SecItemAdd((__bridge CFDictionaryRef)attributes, nil);
// Just log an error if it failed.
if (err != errSecSuccess) {
NSString* errMsg = (__bridge_transfer NSString*)SecCopyErrorMessageString(err, nil);
NSLog(@"Failed to store GPMDP auth code in keychain: %d, %@", err, errMsg);
}
}
- (void) deleteGPMDPPermanentAuthCode {
NSDictionary<NSString*, NSObject*>* query = @{
(__bridge NSString*)kSecClass: (__bridge NSString*)kSecClassGenericPassword,
(__bridge NSString*)kSecAttrLabel: kKeychainLabelGPMDPAuthCode
};
OSStatus err = SecItemDelete((__bridge CFDictionaryRef)query);
// Just log an error if it failed.
if (err != errSecSuccess) {
NSString* errMsg = (__bridge_transfer NSString*)SecCopyErrorMessageString(err, nil);
NSLog(@"Failed to delete GPMDP auth code from keychain: %d, %@", err, errMsg);
}
}
#pragma mark General Accessors
- (id __nullable) get:(NSString*)key {
return defaults ? [defaults objectForKey:key] : transientDefaults[key];

View file

@ -54,8 +54,8 @@
return (DecibelApplication* __nullable)scriptingBridge.application;
}
- (void) onSelect {
[super onSelect];
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}

View file

@ -0,0 +1,46 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMGooglePlayMusicDesktopPlayer.h
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// We have a lot more code for GPMDP than most music players largely because GPMDP has a WebSockets
// API and because the user has to enter a code from GPMDP to allow BGMApp to control it.
// Currently, the other music players all have AppleScript APIs, so for them the OS asks the user
// for permission on our behalf automatically and handles the whole process for us.
//
// This class implements the usual BGMMusicPlayer methods and handles the UI for authenticating
// with GPMDP. BGMGooglePlayMusicDesktopPlayerConnection manages the connection to GPMDP and hides
// the details of its API.
//
// Superclass/Protocol Import
#import "BGMMusicPlayer.h"
#pragma clang assume_nonnull begin
API_AVAILABLE(macos(10.10))
@interface BGMGooglePlayMusicDesktopPlayer : BGMMusicPlayerBase<BGMMusicPlayer>
+ (NSArray<id<BGMMusicPlayer>>*) createInstancesWithDefaults:(BGMUserDefaults*)userDefaults;
@end
#pragma clang assume_nonnull end

View file

@ -0,0 +1,342 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMGooglePlayMusicDesktopPlayer.m
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Self Include
#import "BGMGooglePlayMusicDesktopPlayer.h"
// Local Includes
#import "BGM_Types.h"
#import "BGM_Utils.h"
#import "BGMAppWatcher.h"
#import "BGMGooglePlayMusicDesktopPlayerConnection.h"
// PublicUtility Includes
#import "CADebugMacros.h"
#pragma clang assume_nonnull begin
@implementation BGMGooglePlayMusicDesktopPlayer {
BGMUserDefaults* userDefaults;
BGMGooglePlayMusicDesktopPlayerConnection* connection;
BGMAppWatcher* appWatcher;
// True while the auth code dialog is open. The user types in the four-digit auth code from
// GPMDP when we connect to it for the first time.
BOOL showingAuthCodeDialog;
// True if the user has cancelled the auth code dialog. We only show the auth code dialog again
// after the user has changed the music player and then changed it back to GPMDP (or restarted
// BGMApp).
BOOL authCancelled;
}
+ (NSArray<id<BGMMusicPlayer>>*) createInstancesWithDefaults:(BGMUserDefaults*)userDefaults {
return @[[[self alloc] initWithUserDefaults:userDefaults]];
}
- (instancetype) initWithUserDefaults:(BGMUserDefaults*)defaults {
// If you're copying this class, replace the ID string with a new one generated by uuidgen (the
// command line tool).
NSUUID* playerID = [BGMMusicPlayerBase makeID:@"FCDCC01F-4BF1-4AD2-BE3E-6B7659A90A3F"];
if ((self = [super initWithMusicPlayerID:playerID
name:@"GPMDP"
toolTip:@"Google Play Music Desktop Player"
bundleID:@"google-play-music-desktop-player"])) {
userDefaults = defaults;
showingAuthCodeDialog = NO;
authCancelled = NO;
// We don't strictly need to use a weak ref (at least not yet), but it doesn't hurt.
BGMGooglePlayMusicDesktopPlayer* __weak weakSelf = self;
connection = [[BGMGooglePlayMusicDesktopPlayerConnection alloc]
initWithUserDefaults:userDefaults
authRequiredHandler:^{
BGMGooglePlayMusicDesktopPlayer* strongSelf = weakSelf;
return [strongSelf requestAuthCodeFromUser];
}
connectionErrorHandler:^{
BGMGooglePlayMusicDesktopPlayer* strongSelf = weakSelf;
[strongSelf showConnectionErrorDialog];
}
apiVersionMismatchHandler:^(NSString* reportedAPIVersion) {
BGMGooglePlayMusicDesktopPlayer* strongSelf = weakSelf;
[strongSelf showAPIVersionMismatchDialog:reportedAPIVersion];
}];
// Set up callbacks that run when GPMDP is opened or closed.
appWatcher = [[BGMAppWatcher alloc]
initWithBundleID:BGMNN(self.bundleID)
appLaunched:^{
BGMGooglePlayMusicDesktopPlayer* strongSelf = weakSelf;
[strongSelf gpmdpWasLaunched];
}
appTerminated:^{
BGMGooglePlayMusicDesktopPlayer* strongSelf = weakSelf;
[strongSelf gpmdpWasTerminated];
}];
}
return self;
}
- (void) gpmdpWasLaunched {
if (self.selected) {
// Reconnect so we can control GPMDP.
DebugMsg("BGMGooglePlayMusicDesktopPlayer::gpmdpWasLaunched: GPMDP launched. Connecting");
// Try up to 10 times because GPMDP won't start accepting connections until it's finished
// starting up.
//
// TODO: If GPMDP shows an alert before it finishes launching, it doesn't start accepting
// connections until the alert is dismissed, which can make this can timeout.
// TODO: Is the error dialog still shown if the user closes GPMDP again while we're
// retrying? It shouldn't be.
[connection connectWithRetries:10];
}
}
- (void) gpmdpWasTerminated {
if (self.selected) {
// Allow the connection to clean up and reset itself.
DebugMsg("BGMGooglePlayMusicDesktopPlayer::gpmdpWasTerminated: GPMDP has been closed.");
[connection disconnect];
}
}
- (void) wasSelected {
[super wasSelected];
// Allow the auth code dialog to be shown again if we were hiding it because the user cancelled
// it last time.
authCancelled = NO;
if (self.running) {
// Only retry once so the error message is shown fairly quickly if we fail to connect.
[connection connectWithRetries:1];
}
}
- (void) wasDeselected {
[super wasDeselected];
[connection disconnect];
}
- (NSString* __nullable) requestAuthCodeFromUser {
if (showingAuthCodeDialog) {
DebugMsg("BGMGooglePlayMusicDesktopPlayer::requestAuthCodeFromUser: "
"Already showing the auth code dialog");
return nil;
}
if (authCancelled) {
DebugMsg("BGMGooglePlayMusicDesktopPlayer::requestAuthCodeFromUser: "
"Previously cancelled. Doing nothing.");
return nil;
}
showingAuthCodeDialog = YES;
// Ask the user to read the auth code from GPMDP and type it in to BGMApp.
NSString* __nullable authCode = [self showAuthCodeDialog];
showingAuthCodeDialog = NO;
return authCode;
}
- (NSString* __nullable) showAuthCodeDialog {
// When this isn't being called because the user just changed something in BGMApp (e.g. GPMDP
// was closed, they selected it in BGMApp for the first time, then opened GPMDP later), we could
// use notifications instead of an NSAlert. But it probably wouldn't happen often enough to be
// worth the effort.
NSAlert* alert = [NSAlert new];
alert.messageText = @"Background Music needs permission to control GPMDP.";
alert.informativeText = @"It should be displaying a four-digit code for you to enter.";
[alert addButtonWithTitle:@"OK"];
[alert addButtonWithTitle:@"Cancel"];
// The text field to type the auth code in.
// TODO: Can we derive these dimensions from something instead of hardcoding them?
NSTextField* input = [[NSTextField alloc] initWithFrame:NSMakeRect(0, 0, 50, 24)];
[alert setAccessoryView:input];
// Focus the text field (so the user doesn't have to do it themselves).
[alert.window setInitialFirstResponder:input];
// Bring GMPDP to the front, underneath our NSAlert, so the user can see the auth code.
[self showGPMDPBehindAuthCodeDialog];
NSModalResponse buttonPressed = [alert runModal];
if (buttonPressed == NSAlertFirstButtonReturn) {
// Set input's value to the text entered by the user so we can access it.
[input validateEditing];
DebugMsg("BGMGooglePlayMusicDesktopPlayer::showAuthCodeDialog: Got auth code: <private>");
return input.stringValue;
} else {
DebugMsg("BGMGooglePlayMusicDesktopPlayer::showAuthCodeDialog: "
"The user cancelled the auth code dialog");
authCancelled = YES;
return nil;
}
}
- (void) showGPMDPBehindAuthCodeDialog {
// Dispatched because if we do this just before showing the auth code dialog, the user's current
// active window will be deactivated, the auth code dialog will become the active window and
// macOS will act as if the user activated it themselves. To avoid stealing key focus, it won't
// activate GPMDP.
//
// We could pass NSApplicationActivateIgnoringOtherApps to activateWithOptions instead, but then
// GPMDP would be activated even if the user really did activate a different application, which
// would steal focus from it.
//
// 250 ms is a reasonable value on my system, but won't always be long enough. When it isn't,
// GPMDP won't be activated, but that just means the user will have to do it themselves.
const int64_t delay = 250 * NSEC_PER_MSEC;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay),
dispatch_get_main_queue(),
^{
// Make GMPDP the frontmost app.
NSArray<NSRunningApplication*>* gpmdpApps =
[NSRunningApplication
runningApplicationsWithBundleIdentifier:BGMNN(self.bundleID)];
if (gpmdpApps.count > 0) {
[gpmdpApps[0] activateWithOptions:0];
}
// Focus the auth code dialog. It will already be in front of GPMDP because
// it's modal. Dispatched for the same reason as above.
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay),
dispatch_get_main_queue(),
^{
[NSApp activateIgnoringOtherApps:YES];
});
});
}
- (void) showConnectionErrorDialog {
NSString* errorMsg = @"Could not connect to Google Play Music Desktop Player";
NSString* troubleshootingMsg =
[NSString stringWithFormat:
@"Make sure \"Enable JSON API\" and \"Enable Playback API\" are both checked in GPMDP's "
"settings, then restart GPMDP.\n\n"
"GPMDP should be listening on its default port, 5672.\n\n"
"Consider filing a bug report at %s",
kBGMIssueTrackerURL];
[self showErrorDialog:errorMsg troubleshootingMsg:troubleshootingMsg];
}
- (void) showAPIVersionMismatchDialog:(NSString*)reportedAPIVersion {
NSString* errorMsg = @"Google Play Music Desktop Player Version Not Supported";
NSString* troubleshootingMsg =
[NSString stringWithFormat:
@"GPMDP reported its API version as \"%@\", which Background Music doesn't support "
"yet. Background Music might not be able to control GPMDP properly.\n\n"
"Feel free to open an issue about this at %s",
reportedAPIVersion,
kBGMIssueTrackerURL];
[self showErrorDialog:errorMsg troubleshootingMsg:troubleshootingMsg];
}
- (void) showErrorDialog:(NSString*)errorMsg troubleshootingMsg:(NSString*)troubleshootingMsg {
if (!self.running) {
// GPMDP isn't running, so there's no need to inform the user. (The "Auto-pause GPMDP" menu
// item will be greyed out, but that's handled elsewhere.)
DebugMsg("BGMGooglePlayMusicDesktopPlayer::showErrorDialog: Not running");
return;
}
NSLog(@"%@", errorMsg);
// Show the error in a UI dialog.
NSAlert* alert = [NSAlert new];
alert.messageText = errorMsg;
alert.informativeText = troubleshootingMsg;
// TODO: Show the suppression checkbox and save its value in user defaults.
alert.showsSuppressionButton = NO;
[alert addButtonWithTitle:@"OK"];
[alert runModal];
}
- (BOOL) isRunning {
// We have to check with NSRunningApplication instead of just setting a flag in appWatcher's
// callbacks because BGMAutoPauseMenuItem calls this method when it's notified by its own
// instance of BGMAppWatcher. If BGMAutoPauseMenuItem got notified first, the flag wouldn't be
// updated in time.
//
// At some point we might want to try to avoid this by making the BGMMusicPlayers' running
// properties observable.
NSArray<NSRunningApplication*>* instances =
[NSRunningApplication runningApplicationsWithBundleIdentifier:BGMNN(self.bundleID)];
return instances.count > 0;
}
- (BOOL) isPlaying {
return self.running && connection.playing;
}
- (BOOL) isPaused {
return self.running && connection.paused;
}
- (BOOL) pause {
// isPlaying checks isRunning, so we don't need to check it here.
BOOL wasPlaying = self.playing;
if (wasPlaying) {
DebugMsg("BGMGooglePlayMusicDesktopPlayer::pause: Pausing Google Play Music Desktop "
"Player");
// There's a race condition here and in unpause because, if the user paused GPMDP just
// before we called playPause, GPMDP would play instead of pausing. I'm not sure there's
// much we can/should do about it.
[connection playPause];
}
return wasPlaying;
}
- (BOOL) unpause {
// isPaused checks isRunning, so we don't need to check it here.
BOOL wasPaused = self.paused;
if (wasPaused) {
DebugMsg("BGMGooglePlayMusicDesktopPlayer::unpause: Unpausing Google Play Music Desktop "
"Player");
[connection playPause];
}
return wasPaused;
}
@end
#pragma clang assume_nonnull end

View file

@ -0,0 +1,64 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMGooglePlayMusicDesktopPlayerConnection.h
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Local Includes
#import "BGMUserDefaults.h"
// System Includes
#import <Cocoa/Cocoa.h>
#import <WebKit/WebKit.h>
#pragma clang assume_nonnull begin
API_AVAILABLE(macos(10.10))
@interface BGMGooglePlayMusicDesktopPlayerConnection : NSObject<WKScriptMessageHandler>
// authRequiredHandler: A UI callback that asks the user for the auth code GPMDP will display.
// Returns the auth code they entered, or nil.
// connectionErrorHandler: A UI callback that shows a connection error message.
// apiVersionMismatchHandler: A UI callback that shows a warning dialog explaining that GPMDP
// reported an API version that we don't support yet.
- (instancetype) initWithUserDefaults:(BGMUserDefaults*)defaults
authRequiredHandler:(NSString* __nullable (^)(void))authHandler
connectionErrorHandler:(void (^)(void))errorHandler
apiVersionMismatchHandler:(void (^)(NSString* reportedAPIVersion))apiVersionHandler;
// Returns before the connection has been fully established. The playing and paused properties will
// remain false until the connection is complete, but playPause can be called at any time after
// calling this method.
//
// If the connection fails, it will be retried after a one second delay, up to the number of times
// given.
- (void) connectWithRetries:(int)retries;
- (void) disconnect;
// Tell GPMDP to play if it's paused or pause if it's playing.
- (void) playPause;
@property (readonly) BOOL playing;
@property (readonly) BOOL paused;
@end
#pragma clang assume_nonnull end

View file

@ -0,0 +1,444 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMGooglePlayMusicDesktopPlayerConnection.m
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Self Include
#import "BGMGooglePlayMusicDesktopPlayerConnection.h"
// Local Includes
#import "BGM_Utils.h"
// PublicUtility Includes
#import "CADebugMacros.h"
#pragma clang assume_nonnull begin
// When GooglePlayMusicDesktopPlayer.js sends a message to this class, it sets the message handler
// name to one of these, which tells us what type of message it is. (This is a macro because you
// can't make a static const NSArray.)
#define kScriptMessageHandlerNames (@[@"gpmdp", @"log", @"error"])
@implementation BGMGooglePlayMusicDesktopPlayerConnection {
// GPMDP has a WebSocket API, so we use a WKWebView to access it using Javascript. Using a
// proper library would make the code a bit cleaner and save a little memory, but I'm not sure
// it would be worth adding an external dependency for that.
WKWebView* webView;
NSString* __nullable permanentAuthCode;
BGMUserDefaults* userDefaults;
// The number of times to retry if we fail to connect. For example, if GPMDP is still starting
// up. Set to 0 when we aren't trying to connect.
int connectionRetries;
// A UI callback that asks the user for the auth code GPMDP will display.
NSString* __nullable (^authRequiredHandler)(void);
// A UI callback that shows a connection error message.
void (^connectionErrorHandler)(void);
// A UI callback that shows a warning dialog explaining that GPMDP reported an API version that
// we don't support yet.
void (^apiVersionMismatchHandler)(NSString* reportedAPIVersion);
}
- (instancetype) initWithUserDefaults:(BGMUserDefaults*)defaults
authRequiredHandler:(NSString* __nullable (^)(void))authHandler
connectionErrorHandler:(void (^)(void))errorHandler
apiVersionMismatchHandler:(void (^)(NSString* reportedAPIVersion))apiVersionHandler {
if((self = [super init])) {
userDefaults = defaults;
authRequiredHandler = authHandler;
connectionErrorHandler = errorHandler;
apiVersionMismatchHandler = apiVersionHandler;
connectionRetries = 0;
// Lazily initialised.
permanentAuthCode = nil;
// Report that GPMDP is stopped until we know otherwise.
_playing = NO;
_paused = NO;
}
return self;
}
// Creates and initialises webView, a WKWebView we use to communicate with GPMDP over WebSockets.
- (void) createWebView {
// Read the Javascript we'll need for this.
NSString* __nullable jsPath =
[[NSBundle mainBundle] pathForResource:@"GooglePlayMusicDesktopPlayer.js"
ofType:nil];
NSError* err;
NSString* __nullable jsStr =
(!jsPath ? nil : [NSString stringWithContentsOfFile:BGMNN(jsPath)
encoding:NSUTF8StringEncoding
error:&err]);
if (err || !jsStr || [jsStr isEqualToString:@""]) {
// TODO: Return an error so the caller can show an error dialog or something.
NSLog(@"Error loading GPMDP Javascript file: %@", err);
} else {
webView = [WKWebView new];
// Register to receive messages from our Javascript. The messages are handled in
// userContentController. We register several times using different names as a convenient
// way to separate messages from GPMDP, messages to log and errors.
for (NSString* name in kScriptMessageHandlerNames) {
[webView.configuration.userContentController addScriptMessageHandler:self name:name];
}
// Load our Javascript functions into webView so we can call them later.
[self evaluateJavaScript:BGMNN(jsStr)];
}
}
- (void) connectWithRetries:(int)retries {
if (retries < 0) {
BGMAssert(false, "retries < 0");
return;
}
if (!permanentAuthCode) {
// Read the API auth code from user defaults (actually the keychain), if there is one. If
// the user hasn't authenticated before, it will be nil.
//
// We do this lazily because it can show a password dialog in debug/unsigned builds.
permanentAuthCode = userDefaults.googlePlayMusicDesktopPlayerPermanentAuthCode;
}
connectionRetries = retries;
// Create the WKWebView we'll use to connect to GPMDP with WebSockets. Using a WKWebView means
// Background Music uses a bit more memory while connected to GPMDP, around 15 MB for me, but
// saves us having to complicate the build process to add a dependency on a proper library.
[self createWebView];
if (permanentAuthCode) {
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::connectWithRetries: "
"Connecting with auth code");
NSString* __nullable percentEncodedCode =
[BGMGooglePlayMusicDesktopPlayerConnection
toPercentEncoded:BGMNN(permanentAuthCode)];
[self evaluateJavaScript:[NSString stringWithFormat:@"connect('%@');", percentEncodedCode]];
} else {
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::connectWithRetries: "
"Connecting without auth code");
[self evaluateJavaScript:@"connect();"];
}
// Check whether GPMDP is playing, paused or stopped.
[self requestPlaybackState];
}
- (void) disconnect {
// Stop retrying if we're in the process of connecting.
connectionRetries = 0;
// evaluateJavaScript is only safe to call on the main thread.
dispatch_async(dispatch_get_main_queue(), ^{
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::disconnect: Disconnecting");
[webView evaluateJavaScript:@"disconnect();"
completionHandler:^(id __nullable result, NSError* __nullable error) {
#pragma unused (result)
if (error) {
NSLog(@"Error closing connection to GPMDP: %@", error);
}
// Allow the WKWebView to be garbage collected.
for (NSString* name in kScriptMessageHandlerNames) {
[webView.configuration.userContentController
removeScriptMessageHandlerForName:name];
}
webView = nil;
}];
});
}
- (void) evaluateJavaScript:(NSString*)js {
// evaluateJavaScript is only safe to call on the main thread.
dispatch_async(dispatch_get_main_queue(), ^{
[webView evaluateJavaScript:js
completionHandler:^(id __nullable result, NSError* __nullable error) {
#pragma unused (result)
if (error) {
// TODO: We should probably show an error dialog in some cases.
NSLog(@"JS error: %@", error);
}
}];
});
}
- (void) playPause {
[self evaluateJavaScript:@"playPause();"];
}
- (void) sendAuthCode:(NSString*)authCode {
// Don't log the code itself just in case it could be a security problem.
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::sendAuthCode: Sending GPMDP auth code");
// Percent-encode the user input just in case they entered something that could execute as
// Javascript. We could limit the input to four digits instead, but this should be fine.
NSString* __nullable percentEncodedCode =
[BGMGooglePlayMusicDesktopPlayerConnection toPercentEncoded:authCode];
// We send the message to GPMDP even if percentEncodedCode is nil so it will reply with an error
// and BGMApp will ask the user for the auth code again.
NSString* js = [NSString stringWithFormat:@"window.sendAuthCode('%@');", percentEncodedCode];
[self evaluateJavaScript:js];
}
- (void) sendPermanentAuthCode {
NSString* __nullable code = permanentAuthCode;
if (code) {
// Don't log the code itself just in case it could be a security problem.
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::sendPermanentAuthCode: "
"Sending GPMDP permanent auth code");
// Percent-encode it just in case something it includes could be executed as Javascript.
NSString* __nullable percentEncodedCode =
[BGMGooglePlayMusicDesktopPlayerConnection toPercentEncoded:BGMNN(code)];
// Pass the code to our WKWebView so it can send it to GPMDP.
NSString* js =
[NSString stringWithFormat:@"sendPermanentAuthCode('%@');", percentEncodedCode];
[self evaluateJavaScript:js];
} else {
NSLog(@"BGMGooglePlayMusicDesktopPlayerConnection::sendPermanentAuthCode: No code to send");
}
}
+ (NSString* __nullable)toPercentEncoded:(NSString*)rawString {
// Just percent-encode every character (by passing an empty NSCharacterSet as the allowed
// characters).
NSString* __nullable percentEncoded = [rawString
stringByAddingPercentEncodingWithAllowedCharacters:
[NSCharacterSet characterSetWithCharactersInString:@""]];
if (percentEncoded) {
return percentEncoded;
} else {
// The docs say that stringByAddingPercentEncodingWithAllowedCharacters returns nil "if the
// transformation is not possible", but don't explain when that could happen. According to
// https://stackoverflow.com/a/33558934/1091063 it can be caused by the string containing
// invalid unicode.
NSLog(@"Could not encode");
return nil;
}
}
// Ask GPMDP whether it's playing, paused or stopped. The response is handled asynchronously in
// handleResultMessage.
- (void) requestPlaybackState {
[self evaluateJavaScript:@"requestPlaybackState();"];
}
#pragma mark WKScriptMessageHandler Methods
- (void) userContentController:(WKUserContentController*)userContentController
didReceiveScriptMessage:(WKScriptMessage*)message {
#pragma unused (userContentController)
if ([@"log" isEqual:message.name]) {
// The message body is always a string in this case.
[self handleLogMessage:message.body];
} else if ([@"error" isEqual:message.name]) {
[self handleConnectionError];
} else {
BGMAssert([@"gpmdp" isEqual:message.name], "Unexpected message handler name");
[self handleGPMDPMessage:message];
}
}
- (void) handleLogMessage:(NSString*)message {
(void)message;
#if DEBUG
if (permanentAuthCode) {
// Avoid logging the auth code, which would be a minor security issue.
message = [message stringByReplacingOccurrencesOfString:BGMNN(permanentAuthCode)
withString:@"<private>"];
}
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::userContentController: %s",
message.UTF8String);
#endif
}
- (void) handleConnectionError {
if (connectionRetries > 0) {
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleConnectionError: "
"Retrying in 1 second");
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)),
dispatch_get_main_queue(),
^{
// Check connectionRetries again because disconnect may have been called.
if (connectionRetries > 0) {
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::"
"handleConnectionError: Retrying");
[self connectWithRetries:(connectionRetries - 1)];
}
});
} else {
NSLog(@"BGMGooglePlayMusicDesktopPlayerConnection::handleConnectionError: "
"No retries left. Giving up.");
connectionErrorHandler();
}
}
- (void) handleGPMDPMessage:(WKScriptMessage*)message {
// See https://github.com/MarshallOfSound/Google-Play-Music-Desktop-Player-UNOFFICIAL-/blob/master/docs/PlaybackAPI_WebSocket.md
// Type check.
if (![message.body isKindOfClass:[NSDictionary class]]) {
NSLog(@"Unexpected message body type");
return;
}
NSDictionary* body = message.body;
NSString* messageType;
// The key for the message type is "channel", except when the message is a response, in which
// case the key can be "namespace".
if ([body[@"channel"] isKindOfClass:[NSString class]]) {
messageType = body[@"channel"];
} else if ([body[@"namespace"] isKindOfClass:[NSString class]]) {
messageType = body[@"namespace"];
} else {
NSLog(@"No channel/namespace");
return;
}
// Handle the message depending on its type (or ignore it).
if ([@"API_VERSION" isEqual:messageType]) {
[self handleAPIVersionMessage:body];
} else if ([@"connect" isEqual:messageType]) {
[self handleConnectMessage:body];
} else if ([@"playState" isEqual:messageType]) {
[self handlePlayStateMessage:body];
} else if ([@"result" isEqual:messageType]) {
[self handleResultMessage:body];
}
}
- (void) handleAPIVersionMessage:(NSDictionary*)body {
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleAPIVersionMessage: Response: %s",
[NSString stringWithFormat:@"%@", body].UTF8String);
// Type check.
if (![body[@"payload"] isKindOfClass:[NSString class]]) {
NSLog(@"Unexpected payload type");
[self handleConnectionError];
return;
}
NSString* apiVersion = body[@"payload"];
// "1.0.0" -> ["1", "0", "0"]
NSArray<NSString*>* versionParts = [apiVersion componentsSeparatedByString:@"."];
// Check the major version number is 1, which is the only major version we support.
if (versionParts.count > 0) {
NSInteger majorVersion = versionParts[0].integerValue;
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleAPIVersionMessage: "
"Major version: %lu", majorVersion);
if (majorVersion == 1) {
// GPMDP uses SemVer, so as long as the major version number matches what we can handle,
// it should work.
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleAPIVersionMessage: "
"This API version is supported");
return;
}
}
// Show a warning dialog box to the user, but try to continue anyway. There's probably a
// reasonable chance it'll still work.
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleAPIVersionMessage: "
"Unsupported GPMDP API version");
apiVersionMismatchHandler(apiVersion);
}
- (void) handleConnectMessage:(NSDictionary*)body {
// Don't log the response as it may contain the auth code.
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleConnectMessage: Received response");
// Type check.
if (![body[@"payload"] isKindOfClass:[NSString class]]) {
NSLog(@"Unexpected payload type");
[self handleConnectionError];
return;
}
NSString* payload = body[@"payload"];
if ([@"CODE_REQUIRED" isEqual:payload]) {
// Ask the user for the auth code GPMDP is displaying and send it to GPMDP to finish
// connecting.
NSString* __nullable authCode = authRequiredHandler();
if (authCode) {
[self sendAuthCode:BGMNN(authCode)];
}
} else {
// The payload should be the permanent auth code.
permanentAuthCode = payload;
[self sendPermanentAuthCode];
// Save the code to the keychain so we can use it when connecting to GPMDP in future.
userDefaults.googlePlayMusicDesktopPlayerPermanentAuthCode = permanentAuthCode;
}
}
- (void) handlePlayStateMessage:(NSDictionary*)body {
(void)body;
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handlePlayStateMessage: Response: %s",
[NSString stringWithFormat:@"%@", body].UTF8String);
// This message tells us the playstate has changed, but doesn't differentiate between stopped
// and paused. The response to this API request will. See handleResultMessage.
// TODO: Can it transition from stopped to paused? Would that be a problem?
[self requestPlaybackState];
}
- (void) handleResultMessage:(NSDictionary*)body {
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleResultMessage: Response: %s",
[NSString stringWithFormat:@"%@", body].UTF8String);
// Type check.
if (![body[@"value"] isKindOfClass:[NSNumber class]]) {
NSLog(@"No value");
return;
}
// 0 - Playback is stopped
// 1 - Track is paused
// 2 - Track is playing
int state = ((NSNumber*)body[@"value"]).intValue;
_playing = (state == 2);
_paused = (state == 1);
}
@end
#pragma clang assume_nonnull end

View file

@ -54,8 +54,8 @@
return (HermesApplication* __nullable)scriptingBridge.application;
}
- (void) onSelect {
[super onSelect];
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}

View file

@ -17,7 +17,7 @@
// BGMMusicPlayer.h
// BGMApp
//
// Copyright © 2016, 2018 Kyle Neideck
// Copyright © 2016, 2018, 2019 Kyle Neideck
//
// The base classes and protocol for objects that represent a music player app.
//
@ -41,6 +41,9 @@
// BGMDriver will log the bundle ID to system.log when it becomes aware of the music player.
//
// Local Includes
#import "BGMUserDefaults.h"
// System Includes
#import <Cocoa/Cocoa.h>
@ -50,27 +53,26 @@
@protocol BGMMusicPlayer <NSObject>
// Classes return an instance of themselves for each music player app they make available in
// BGMApp. So far that's always been a single instance, and classes haven't needed to override
// the default implementation of createInstances from BGMMusicPlayerBase. But that will probably
// change eventually.
// BGMApp. So far that's always been a single instance, but that will probably change eventually.
// Most classes don't need to override the default implementation from BGMMusicPlayerBase.
//
// For example, a class for custom music players would probably return an instance for each
// custom player the user has created. (Also note that it could return an empty array.) In that
// case the class would probably restore some state from user defaults in its createInstances.
// But, for example, a class for custom music players would probably return an instance for each
// custom player the user has created. (Also note that it could return an empty array.)
//
// TODO: I think the return type should actually be NSArray<instancetype>*, but that doesn't seem
// to work. There's a Clang bug about this: https://llvm.org/bugs/show_bug.cgi?id=27323
// (though it hasn't been confirmed yet).
+ (NSArray<id<BGMMusicPlayer>>*) createInstances;
+ (NSArray<id<BGMMusicPlayer>>*) createInstancesWithDefaults:(BGMUserDefaults*)userDefaults;
// We need a unique ID for each music player to store in user defaults. In the most common case,
// classes that provide a static (or at least bounded) number of music players, you can generate
// IDs with uuidgen (the command line tool) and include them in your class as constants. Otherwise,
// you'll probably want to store them in user defaults and retrieve them in your createInstances.
// you'll probably want to store them in user defaults and load them in createInstancesWithDefaults.
@property (readonly) NSUUID* musicPlayerID;
// The name and icon of the music player, to be used in the UI.
// The name, tool-tip and icon of the music player, to be used in the UI.
@property (readonly) NSString* name;
@property (readonly) NSString* __nullable toolTip;
@property (readonly) NSImage* __nullable icon;
@property (readonly) NSString* __nullable bundleID;
@ -81,7 +83,7 @@
// TODO: If we ever add a music player class that uses this property, it'll need a way to inform
// BGMDevice of changes. It might be easiest to have BGMMusicPlayers to observe this property,
// on the selected music player, with KVO and update BGMDevice when it changes. Or
// BGMMusicPlayers could pass a pointer to itself to createInstances.
// BGMMusicPlayers could pass a pointer to itself to createInstancesWithDefaults.
@property NSNumber* __nullable pid;
// True if this is currently the selected music player.
@ -101,9 +103,9 @@
@property (readonly, getter=isPaused) BOOL paused;
// Called when the user selects this music player.
- (void) onSelect;
// Called when this is the selected music player and the user selects a different one.
- (void) onDeselect;
- (void) wasSelected;
// Called when this was the selected music player and the user just selected a different one.
- (void) wasDeselected;
// Pause the music player. Does nothing if the music player is already paused or isn't running.
// Returns YES if the music player is paused now but wasn't before, returns NO otherwise.
@ -123,6 +125,12 @@
- (instancetype) initWithMusicPlayerID:(NSUUID*)musicPlayerID
name:(NSString*)name
toolTip:(NSString*)toolTip
bundleID:(NSString* __nullable)bundleID;
- (instancetype) initWithMusicPlayerID:(NSUUID*)musicPlayerID
name:(NSString*)name
toolTip:(NSString* __nullable)toolTip
bundleID:(NSString* __nullable)bundleID
pid:(NSNumber* __nullable)pid;
@ -131,15 +139,16 @@
+ (NSUUID*) makeID:(NSString*)musicPlayerIDString;
// BGMMusicPlayer default implementations
+ (NSArray<id<BGMMusicPlayer>>*) createInstances;
+ (NSArray<id<BGMMusicPlayer>>*) createInstancesWithDefaults:(BGMUserDefaults*)userDefaults;
@property (readonly) NSImage* __nullable icon;
@property (readonly) NSUUID* musicPlayerID;
@property (readonly) NSString* name;
@property (readonly) NSString* __nullable toolTip;
@property (readonly) NSString* __nullable bundleID;
@property NSNumber* __nullable pid;
@property (readonly) BOOL selected;
- (void) onSelect;
- (void) onDeselect;
- (void) wasSelected;
- (void) wasDeselected;
@end

View file

@ -17,7 +17,7 @@
// BGMMusicPlayer.m
// BGMApp
//
// Copyright © 2016-2018 Kyle Neideck
// Copyright © 2016-2019 Kyle Neideck
//
// Self Include
@ -33,6 +33,7 @@
@synthesize musicPlayerID = _musicPlayerID;
@synthesize name = _name;
@synthesize toolTip = _toolTip;
@synthesize bundleID = _bundleID;
@synthesize pid = _pid;
@synthesize selected = _selected;
@ -40,11 +41,27 @@
- (instancetype) initWithMusicPlayerID:(NSUUID*)musicPlayerID
name:(NSString*)name
bundleID:(NSString* __nullable)bundleID {
return [self initWithMusicPlayerID:musicPlayerID name:name bundleID:bundleID pid:nil];
return [self initWithMusicPlayerID:musicPlayerID
name:name
toolTip:nil
bundleID:bundleID
pid:nil];
}
- (instancetype) initWithMusicPlayerID:(NSUUID*)musicPlayerID
name:(NSString*)name
toolTip:(NSString*)toolTip
bundleID:(NSString* __nullable)bundleID {
return [self initWithMusicPlayerID:musicPlayerID
name:name
toolTip:toolTip
bundleID:bundleID
pid:nil];
}
- (instancetype) initWithMusicPlayerID:(NSUUID*)musicPlayerID
name:(NSString*)name
toolTip:(NSString* __nullable)toolTip
bundleID:(NSString* __nullable)bundleID
pid:(NSNumber* __nullable)pid {
if ((self = [super init])) {
@ -55,6 +72,7 @@
_musicPlayerID = musicPlayerID;
_name = name;
_toolTip = toolTip;
_bundleID = bundleID;
_pid = pid;
_selected = NO;
@ -72,7 +90,8 @@
#pragma mark BGMMusicPlayer default implementations
+ (NSArray<id<BGMMusicPlayer>>*) createInstances {
+ (NSArray<id<BGMMusicPlayer>>*) createInstancesWithDefaults:(BGMUserDefaults*)userDefaults {
#pragma unused (userDefaults)
return @[ [self new] ];
}
@ -84,11 +103,11 @@
return (!bundlePath ? nil : [[NSWorkspace sharedWorkspace] iconForFile:(NSString*)bundlePath]);
}
- (void) onSelect {
- (void) wasSelected {
_selected = YES;
}
- (void) onDeselect {
- (void) wasDeselected {
_selected = NO;
}

View file

@ -17,7 +17,7 @@
// BGMMusicPlayers.h
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016, 2019 Kyle Neideck
//
// Holds the music players (i.e. BGMMusicPlayer objects) available in BGMApp. Also keeps track of
// which music player is currently selected by the user.
@ -43,8 +43,8 @@
// defaultMusicPlayerID is the musicPlayerID (see BGMMusicPlayer.h) of the music player that should be
// selected by default.
//
// The createInstances method of each class in musicPlayerClasses will be called, and the results stored
// in the musicPlayers property.
// The createInstancesWithDefaults method of each class in musicPlayerClasses will be called and
// the results will be stored in the musicPlayers property.
- (instancetype) initWithAudioDevices:(BGMAudioDeviceManager*)devices
defaultMusicPlayerID:(NSUUID*)defaultMusicPlayerID
musicPlayerClasses:(NSArray<Class<BGMMusicPlayer>>*)musicPlayerClasses

View file

@ -17,7 +17,7 @@
// BGMMusicPlayers.mm
// BGMApp
//
// Copyright © 2016-2018 Kyle Neideck
// Copyright © 2016-2019 Kyle Neideck
//
// Self include
@ -34,6 +34,7 @@
#import "BGMDecibel.h"
#import "BGMHermes.h"
#import "BGMSwinsian.h"
#import "BGMGooglePlayMusicDesktopPlayer.h"
#pragma clang assume_nonnull begin
@ -47,16 +48,24 @@
- (instancetype) initWithAudioDevices:(BGMAudioDeviceManager*)devices
userDefaults:(BGMUserDefaults*)defaults {
// The classes handling each music player we support. If you write a new music player class, add
// it to this array.
NSArray<Class<BGMMusicPlayer>>* mpClasses = @[ [BGMVOX class],
[BGMVLC class],
[BGMSpotify class],
[BGMiTunes class],
[BGMDecibel class],
[BGMHermes class],
[BGMSwinsian class] ];
// We only support Google Play Music Desktop Player on macOS 10.10 and higher.
if (@available(macOS 10.10, *)) {
mpClasses = [mpClasses arrayByAddingObject:[BGMGooglePlayMusicDesktopPlayer class]];
}
return [self initWithAudioDevices:devices
defaultMusicPlayerID:[BGMiTunes sharedMusicPlayerID]
// If you write a new music player class, add it to this array.
musicPlayerClasses:@[ [BGMVOX class],
[BGMVLC class],
[BGMSpotify class],
[BGMiTunes class],
[BGMDecibel class],
[BGMHermes class],
[BGMSwinsian class] ]
musicPlayerClasses:mpClasses
userDefaults:defaults];
}
@ -70,11 +79,14 @@
// Init _musicPlayers, an array containing one object for each music player in BGMApp.
//
// Each music player class has a factory method, createInstances, that returns all the instances of that
// class BGMApp will use. (Though so far it's always just one instance.)
// Each music player class has a factory method, createInstancesWithDefaults, that returns
// all the instances of that class BGMApp will use. (Though so far it's always just one
// instance.)
NSMutableArray* musicPlayers = [NSMutableArray new];
for (Class<BGMMusicPlayer> musicPlayerClass in musicPlayerClasses) {
[musicPlayers addObjectsFromArray:[musicPlayerClass createInstances]];
NSArray<id<BGMMusicPlayer>>* instances =
[musicPlayerClass createInstancesWithDefaults:userDefaults];
[musicPlayers addObjectsFromArray:instances];
}
_musicPlayers = [NSArray arrayWithArray:musicPlayers];
@ -215,7 +227,7 @@
}
// Tell the current music player (object) a different player has been selected.
[_selectedMusicPlayer onDeselect];
[_selectedMusicPlayer wasDeselected];
_selectedMusicPlayer = newSelectedMusicPlayer;
@ -229,7 +241,7 @@
userDefaults.selectedMusicPlayerID = _selectedMusicPlayer.musicPlayerID.UUIDString;
// Tell the music player (object) it's been selected.
[_selectedMusicPlayer onSelect];
[_selectedMusicPlayer wasSelected];
}
- (void) updateBGMDeviceMusicPlayerProperties {

View file

@ -17,7 +17,7 @@
// BGMScriptingBridge.m
// BGMApp
//
// Copyright © 2016-2018 Kyle Neideck
// Copyright © 2016-2019 Kyle Neideck
//
// Self Include
@ -25,6 +25,7 @@
// Local Includes
#import "BGM_Utils.h"
#import "BGMAppWatcher.h"
// PublicUtility Includes
#import "CADebugMacros.h"
@ -34,8 +35,7 @@
@implementation BGMScriptingBridge {
id<BGMMusicPlayer> __weak _musicPlayer;
// Tokens for the notification observers. We need these to remove the observers in dealloc.
id _didLaunchToken, _didTerminateToken;
BGMAppWatcher* appWatcher;
}
@synthesize application = _application;
@ -57,17 +57,14 @@
BGMScriptingBridge* __weak weakSelf = self;
void (^createSBApplication)(void) = ^{
BGMScriptingBridge* __strong strongSelf = weakSelf;
BGMScriptingBridge* strongSelf = weakSelf;
strongSelf->_application = [SBApplication applicationWithBundleIdentifier:bundleID];
// TODO: I think the SBApplication will still keep a strong ref to this object, so we might
// have to make a separate delegate object.
// TODO: The SBApplication will still keep a strong ref to this object, so we would have to
// make a separate delegate object to avoid the retain cycle. Not currently a problem
// because we only ever create instances that live forever.
strongSelf->_application.delegate = strongSelf;
};
BOOL (^isAboutThisMusicPlayer)(NSNotification*) = ^(NSNotification* note) {
return [[note.userInfo[NSWorkspaceApplicationKey] bundleIdentifier] isEqualToString:bundleID];
};
// Add observers that create/destroy the SBApplication when the music player is launched/terminated. We
// only create the SBApplication when the music player is open. If it isn't open, creating the
// SBApplication or sending it events could launch the music player. Whether or not it does depends on
@ -76,31 +73,20 @@
// From the docs for SBApplication's applicationWithBundleIdentifier method:
// "For applications that declare themselves to have a dynamic scripting interface, this method will
// launch the application if it is not already running."
NSNotificationCenter* center = [[NSWorkspace sharedWorkspace] notificationCenter];
_didLaunchToken = [center addObserverForName:NSWorkspaceDidLaunchApplicationNotification
object:nil
queue:nil
usingBlock:^(NSNotification* note)
{
if (isAboutThisMusicPlayer(note)) {
DebugMsg("BGMScriptingBridge::initApplication: %s launched",
bundleID.UTF8String);
createSBApplication();
[weakSelf ensurePermission];
}
}];
_didTerminateToken = [center addObserverForName:NSWorkspaceDidTerminateApplicationNotification
object:nil
queue:nil
usingBlock:^(NSNotification* note)
{
if (isAboutThisMusicPlayer(note)) {
DebugMsg("BGMScriptingBridge::initApplication: %s terminated",
bundleID.UTF8String);
BGMScriptingBridge* __strong strongSelf = weakSelf;
strongSelf->_application = nil;
}
}];
appWatcher =
[[BGMAppWatcher alloc] initWithBundleID:bundleID
appLaunched:^{
DebugMsg("BGMScriptingBridge::initApplication: %s launched",
bundleID.UTF8String);
createSBApplication();
[weakSelf ensurePermission];
}
appTerminated:^{
BGMScriptingBridge* strongSelf = weakSelf;
DebugMsg("BGMScriptingBridge::initApplication: %s terminated",
bundleID.UTF8String);
strongSelf->_application = nil;
}];
// Create the SBApplication if the music player is already running.
if ([NSRunningApplication runningApplicationsWithBundleIdentifier:bundleID].count > 0) {
@ -108,19 +94,6 @@
}
}
- (void) dealloc {
// Remove the application launch/termination observers.
NSNotificationCenter* center = [NSWorkspace sharedWorkspace].notificationCenter;
if (_didLaunchToken) {
[center removeObserver:_didLaunchToken];
}
if (_didTerminateToken) {
[center removeObserver:_didTerminateToken];
}
}
- (void) ensurePermission {
// Skip this check if running on a version of macOS before 10.14. In that case, we don't require
// user permission to send Apple Events. Also skip it if compiling on an earlier version.

View file

@ -57,8 +57,8 @@
return (SpotifyApplication* __nullable)scriptingBridge.application;
}
- (void) onSelect {
[super onSelect];
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}

View file

@ -57,8 +57,8 @@
return (SwinsianApplication* __nullable)scriptingBridge.application;
}
- (void) onSelect {
[super onSelect];
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}

View file

@ -54,8 +54,8 @@
return (VLCApplication*)scriptingBridge.application;
}
- (void) onSelect {
[super onSelect];
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}

View file

@ -53,8 +53,8 @@
return (VoxApplication*)scriptingBridge.application;
}
- (void) onSelect {
[super onSelect];
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}

View file

@ -59,8 +59,8 @@
return (iTunesApplication*)scriptingBridge.application;
}
- (void) onSelect {
[super onSelect];
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}

View file

@ -0,0 +1,190 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// GooglePlayMusicDesktopPlayer.js
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// The specification for GPMDP's API:
// https://github.com/MarshallOfSound/Google-Play-Music-Desktop-Player-UNOFFICIAL-/blob/master/docs/PlaybackAPI_WebSocket.md
try {
window._log = msg => {
window.webkit.messageHandlers.log.postMessage(msg);
};
// Global JS error handler.
window.onerror = (msg, url, line, col, error) => {
let extra = !col ? '' : '\nColumn: ' + col;
extra += !error ? '' : '\nError: ' + error;
// TODO: I'm not sure this log message is ever actually useful.
window._log('Error: ' + msg + '\nURL: ' + url + '\nLine: ' + line + extra);
window.webkit.messageHandlers.error.postMessage(error);
};
// Send a JSON message to GPMDP.
//
// If we're connecting, this function will return immediately and the message will be sent after we
// finish connecting. Logs an error and returns if window.connect() hasn't been called yet.
window._sendJSON = json => {
if (window._wsPromise) {
window._wsPromise.then(() => {
window._sendJSONImmediate(json);
}).catch(error => {
// TODO: Is there anything else we can do? Retries?
window._log('Error sending JSON: ' + JSON.stringify(error));
});
} else {
window._log('Error: No WebSocket promise. Discarding JSON message: ' +
JSON.stringify(json));
}
};
// Send a JSON message to GPMDP, but don't wait if we're in the process of connecting.
//
// Logs an error and returns if window.connect() hasn't been called yet. The authCode param is
// optional and only used to hide the code in log messages.
window._sendJSONImmediate = (json, authCode) => {
let jsonStr = JSON.stringify(json);
let jsonStrSanitized = authCode ? jsonStr.replace(authCode, "<private>") : jsonStr;
if (window._ws) {
window._log('Sending JSON: ' + jsonStrSanitized);
window._ws.send(jsonStr);
} else {
window._log('Error: No WebSocket. Discarding JSON message: ' + jsonStrSanitized);
}
};
// permanentAuthCode is optional. If this is the first time they've selected GPMDP, we won't have a
// permanent code yet.
window.connect = permanentAuthCode => {
// Reset the connection state.
window._requestID = 1;
// Close the existing connection if we're already connected.
window.disconnect();
// Create the new connection.
window._ws = new WebSocket('ws://localhost:5672');
window._ws.onmessage = event => {
// Pass the message along to BGMGooglePlayMusicDesktopPlayerConnection.
let reply = JSON.parse(event.data);
window.webkit.messageHandlers.gpmdp.postMessage(reply);
};
window._wsPromise = new Promise((resolve, reject) => {
window._ws.onopen = () => {
// Send GPMDP the initial connection message.
if (permanentAuthCode) {
window._log('Connecting with auth code');
window.sendPermanentAuthCode(permanentAuthCode);
} else {
// Since we don't have an auth code, it will display a four-digit code and reply
// telling us to ask the user to type it into Background Music.
window._log('Connecting without auth code');
window._sendJSONImmediate({
'namespace': 'connect',
'method': 'connect',
'arguments': ['Background Music']
});
}
};
window._ws.onerror = error => {
// Report the error to BGMGooglePlayMusicDesktopPlayerConnection.
window.webkit.messageHandlers.error.postMessage(error);
// Reject the connection promise.
reject(error);
};
// Store the function that resolves this promise. We resolve it after we finish
// authenticating.
window._resolveConnectionPromise = resolve;
});
};
// Close the connection to GPMDP. Does nothing if we aren't connected.
window.disconnect = () => {
if (window._ws) {
window._log('Closing WebSocket');
window._ws.close();
window._ws = null;
}
};
// Send an authentication code to GPMDP. To send a four-digit code (i.e. one entered by the user),
// call this directly. To send a permanent code received from GPMDP, use
// window.sendPermanentAuthCode().
//
// authCode should be percent-encoded.
window.sendAuthCode = authCode => {
// Percent-decode the auth code string. We pass it percent-encoded just to make sure nothing in
// it accidentally gets executed as Javascript.
authCode = window.decodeURIComponent(authCode);
window._sendJSONImmediate({
'namespace': 'connect',
'method': 'connect',
'arguments': ['Background Music', authCode]
}, authCode);
};
// Send a permanent authentication code, received from GPMDP previously, to GPMDP.
window.sendPermanentAuthCode = permanentAuthCode => {
window._log('Sending permanent auth code');
window.sendAuthCode(permanentAuthCode);
// TODO: If the code is rejected, GPMDP will send us a connect message and we'll show the auth
// code dialog, but accepting the promise here means some messages we send might get
// ignored.
window._resolveConnectionPromise();
};
// Ask GPMDP to send us its current playback state (playing, paused or stopped).
window.requestPlaybackState = () => {
window._sendJSON({
'namespace': 'playback',
'method': 'getPlaybackState',
// We don't send any other types of request, so the ID we send only needs to be unique.
'requestID': window._requestID++
});
};
// Tell GPMDP to toggle between playing and paused.
window.playPause = () => {
window._sendJSON({
'namespace': 'playback',
'method': 'playPause'
});
};
} catch (error) {
window.webkit.messageHandlers.log.postMessage('Error: ' + JSON.stringify(error));
window.webkit.messageHandlers.log.postMessage(JSON.stringify(error.stack));
window.webkit.messageHandlers.error.postMessage(error);
}
// Return an empty string as returning some types can cause an error when this Javascript is loaded
// into the WKWebView.
""

View file

@ -17,7 +17,7 @@
// BGMAutoPauseMusicPrefs.mm
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016, 2019 Kyle Neideck
//
// Self Includes
@ -69,6 +69,7 @@ static NSInteger const kPrefsMenuAutoPauseHeaderTag = 1;
action:@selector(handleMusicPlayerChange:)
keyEquivalent:@""
atIndex:musicPlayerItemsIndex];
menuItem.toolTip = musicPlayer.toolTip;
musicPlayerMenuItems = [musicPlayerMenuItems arrayByAddingObject:menuItem];