├── src ├── versions │ ├── __init__.py │ └── v00001 │ │ ├── __init__.py │ │ ├── install.py │ │ ├── suspender.py │ │ ├── utils.py │ │ ├── process.py │ │ └── main.py ├── uuid.py ├── happymac.py ├── license.py ├── preferences.py ├── tests │ ├── test_license.py │ ├── test_log.py │ ├── test_preferences.py │ ├── test_version_manager.py │ ├── test_versions_v00001_utils.py │ ├── test_versions_v00001_suspender.py │ ├── test_versions_v00001_process.py │ └── test_versions_v00001_main.py ├── gentests.py ├── log.py ├── error.py ├── version.py └── version_manager.py ├── app ├── happymac.app.template │ └── Contents │ │ ├── PkgInfo │ │ ├── Resources │ │ └── app.icns │ │ └── Info.plist ├── pyinstaller.spec └── Info.plist ├── icons ├── ok.png ├── app.icns ├── burn.png ├── frown.png ├── happy.png ├── happymac.png ├── sweating.png ├── unhappy.png ├── nauseated.png ├── happy-transparent.png ├── unhappy-transparent.png ├── happy-white-transparent.png └── unhappy-white-transparent.png ├── requirements.txt ├── install.sh ├── install-quartz.sh ├── LICENSE ├── sign.sh ├── .gitignore └── README.md /src/versions/__init__.py: -------------------------------------------------------------------------------- 1 | import v00001 2 | -------------------------------------------------------------------------------- /src/versions/v00001/__init__.py: -------------------------------------------------------------------------------- 1 | import main 2 | -------------------------------------------------------------------------------- /app/happymac.app.template/Contents/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /icons/ok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/ok.png -------------------------------------------------------------------------------- /icons/app.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/app.icns -------------------------------------------------------------------------------- /icons/burn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/burn.png -------------------------------------------------------------------------------- /icons/frown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/frown.png -------------------------------------------------------------------------------- /icons/happy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/happy.png -------------------------------------------------------------------------------- /icons/happymac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/happymac.png -------------------------------------------------------------------------------- /icons/sweating.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/sweating.png -------------------------------------------------------------------------------- /icons/unhappy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/unhappy.png -------------------------------------------------------------------------------- /icons/nauseated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/nauseated.png -------------------------------------------------------------------------------- /icons/happy-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/happy-transparent.png -------------------------------------------------------------------------------- /icons/unhappy-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/unhappy-transparent.png -------------------------------------------------------------------------------- /icons/happy-white-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/happy-white-transparent.png -------------------------------------------------------------------------------- /icons/unhappy-white-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/icons/unhappy-white-transparent.png -------------------------------------------------------------------------------- /app/happymac.app.template/Contents/Resources/app.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laffra/happymac/HEAD/app/happymac.app.template/Contents/Resources/app.icns -------------------------------------------------------------------------------- /src/uuid.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | def get_hardware_uuid(): 4 | return os.popen("system_profiler SPHardwareDataType | grep UUID | sed 's/.* //' ").read() 5 | -------------------------------------------------------------------------------- /src/happymac.py: -------------------------------------------------------------------------------- 1 | #pylint: disable=E0401 2 | 3 | import error 4 | import sys 5 | import version_manager 6 | 7 | try: 8 | version_manager.main() 9 | except: 10 | error.error("Could not launch HappyMac") 11 | sys.exit(1) 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | altgraph==0.17 2 | certifi==2020.11.8 3 | chardet==3.0.4 4 | dis3==0.1.3 5 | idna==2.10 6 | macholib==1.14 7 | psutil==5.7.3 8 | PyInstaller>=3.6 9 | pyobjc-core==5.3 10 | pyobjc-framework-Cocoa==5.3 11 | pyobjc-framework-Quartz==5.3 12 | requests==2.25.0 13 | rumps==0.3.0 14 | urllib3>=1.26.5 15 | -------------------------------------------------------------------------------- /src/versions/v00001/install.py: -------------------------------------------------------------------------------- 1 | import utils 2 | import os 3 | import preferences 4 | 5 | APP_LOCATION = "/Applications/happymac.app" 6 | SETUP_SCRIPT = 'tell application "System Events" to make login item at end with properties {path:"%s", hidden:false}' % APP_LOCATION 7 | LAUNCH_AT_LOGIN_KEY = "ENABLE_LAUNCH_AT_LOGIN" 8 | 9 | if os.path.exists(APP_LOCATION): 10 | if preferences.get(LAUNCH_AT_LOGIN_KEY): 11 | preferences.set(LAUNCH_AT_LOGIN_KEY, "true") 12 | utils.run_osa_script(SETUP_SCRIPT) 13 | 14 | -------------------------------------------------------------------------------- /src/license.py: -------------------------------------------------------------------------------- 1 | import error 2 | import json 3 | import log 4 | import preferences 5 | import requests 6 | import uuid 7 | 8 | def get_license(): 9 | try: 10 | return preferences.get("license") or download_license() 11 | except: 12 | log.log("Cannot find license") 13 | 14 | def download_license(): 15 | url = "https://www.happymac.app/_functions/agree/?token=%s" % uuid.get_hardware_uuid() 16 | log.log("Getting license from: %s" % url) 17 | license = requests.get(url).content 18 | key = json.loads(license)["key"] 19 | log.log("Received license key: %s" % key) 20 | preferences.set("license", key) -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | 2 | sudo easy_install --upgrade pip 3 | 4 | # Project dependencies: 5 | PIP=pip 6 | 7 | 8 | echo "### Install Python dependencies" 9 | sudo $PIP install pycairo 10 | sudo $PIP install pyobjc-core 11 | sudo $PIP install pyobjc-framework-Quartz 12 | sudo $PIP install AppKit 13 | sudo $PIP install py2app 14 | sudo $PIP install rumps 15 | sudo $PIP install psutil 16 | sudo $PIP install requests 17 | sudo $PIP install chardet 18 | sudo $PIP install pyinstaller==3.4 19 | 20 | echo "### SETUP Dependencies" 21 | brew install pkg-config libffi 22 | export PKG_CONFIG_PATH=/usr/local/Cellar/libffi/3.2.1/lib/pkgconfig/:$PKG_CONFIG_PATH 23 | brew install gobject-introspection 24 | brew install cairo 25 | 26 | 27 | brew install gdbm 28 | 29 | ./install-quartz.sh 30 | 31 | rm -rf .eggs 32 | -------------------------------------------------------------------------------- /install-quartz.sh: -------------------------------------------------------------------------------- 1 | PIP=/usr/bin/pip3 2 | 3 | RED='\x1B[0;31m' 4 | NC='\x1B[0m' 5 | 6 | 7 | # Installation of Quartz is harder 8 | # 9 | # See explanation at https://stackoverflow.com/questions/42530309/no-such-file-requirements-txt-error-while-installing-quartz-module 10 | # 11 | echo "${RED}### SETUP Quartz${NC}" 12 | rm -f quartz-0.0.1.dev0.tar.gz 13 | echo "${RED}### Download Quartz${NC}" 14 | $PIP download --no-deps --no-build-isolation quartz 15 | echo "${RED}### Unzip Quartz${NC}" 16 | gunzip quartz-0.0.1.dev0.tar.gz 17 | tar xvf quartz-0.0.1.dev0.tar 18 | sed "s/requirements.txt/quartz.egg-info\/requires.txt/" < quartz-0.0.1.dev0/setup.py > quartz-0.0.1.dev0/setup2.py 19 | mv quartz-0.0.1.dev0/setup2.py quartz-0.0.1.dev0/setup.py 20 | echo "${RED}### Install Quartz${NC}" 21 | sudo $PIP install -e quartz-0.0.1.dev0 22 | rm -rf quartz-0.0.1.dev0* 23 | 24 | rm -rf .eggs 25 | -------------------------------------------------------------------------------- /src/preferences.py: -------------------------------------------------------------------------------- 1 | import log 2 | import os 3 | import sys 4 | 5 | try: 6 | import cPickle as pickle 7 | except ImportError: 8 | import pickle as pickle 9 | 10 | def get_preferences_path(): 11 | home_dir = os.path.join(os.path.expanduser("~"), "HappyMacApp") 12 | if not os.path.exists(home_dir): 13 | os.makedirs(home_dir) 14 | return os.path.join(home_dir, "happymac.prefs") 15 | 16 | preferences = {} 17 | 18 | if os.path.exists(get_preferences_path()): 19 | with open(get_preferences_path(), "rb") as file: 20 | preferences = pickle.load(file) 21 | 22 | def get(key, default=None): 23 | return preferences.get(key, default) 24 | 25 | def set(key, value): 26 | preferences[key] = value 27 | with open(get_preferences_path(), "wb") as file: 28 | pickle.dump(preferences, file, 2) 29 | log.log("Set preference %s to %s" % (key, repr(value))) 30 | -------------------------------------------------------------------------------- /app/pyinstaller.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | block_cipher = None 4 | 5 | 6 | a = Analysis(['src/happymac.py'], 7 | pathex=['/Users/chris/dev/happymac'], 8 | binaries=[], 9 | datas=[ 10 | ('icons', 'icons'), 11 | ], 12 | hiddenimports=['versions.v00001.main'], 13 | hookspath=[], 14 | runtime_hooks=[], 15 | excludes=['tkinter'], 16 | win_no_prefer_redirects=False, 17 | win_private_assemblies=False, 18 | cipher=block_cipher, 19 | noarchive=False) 20 | pyz = PYZ(a.pure, a.zipped_data, 21 | cipher=block_cipher) 22 | exe = EXE(pyz, 23 | a.scripts, 24 | a.binaries, 25 | a.zipfiles, 26 | a.datas, 27 | [], 28 | name='happymac', 29 | debug=False, 30 | bootloader_ignore_signals=False, 31 | strip=False, 32 | upx=True, 33 | runtime_tmpdir=None, 34 | console=True ) -------------------------------------------------------------------------------- /src/tests/test_license.py: -------------------------------------------------------------------------------- 1 | # 2 | # TODO: Fix tests, needs work on Auger's automatic test generator 3 | # 4 | from collections import defaultdict 5 | import datetime 6 | import error 7 | import json 8 | import license 9 | import log 10 | from mock import patch 11 | import os 12 | import os.path 13 | import preferences 14 | import process 15 | import psutil 16 | from psutil import Popen 17 | import requests 18 | import sys 19 | import unittest 20 | import utils 21 | import versions.v00001.process 22 | import versions.v00001.suspender 23 | from versions.v00001.suspender import defaultdict 24 | import versions.v00001.utils 25 | from versions.v00001.utils import OnMainThread 26 | 27 | 28 | class LicenseTest(unittest.TestCase): 29 | @patch.object(preferences, 'get') 30 | def test_get_license(self, mock_get): 31 | mock_get.return_value = None 32 | self.assertEqual( 33 | license.get_license(), 34 | u'0e69a4f4-ae73-47f8-8d87-7205b4b96e15' 35 | ) 36 | 37 | 38 | if __name__ == "__main__": 39 | unittest.main() 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 laffra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/gentests.py: -------------------------------------------------------------------------------- 1 | #pylint: disable=E0401 2 | #pylint: disable=E1121 3 | 4 | import auger 5 | import traceback 6 | 7 | tester = None 8 | 9 | def start(): 10 | print "######## Auger testing start" 11 | 12 | import error 13 | import license 14 | import log 15 | import preferences 16 | import version_manager 17 | import versions 18 | 19 | test_subjects = [ 20 | error, 21 | log, 22 | preferences, 23 | version_manager, 24 | license, 25 | versions.v00001.install, 26 | versions.v00001.main, 27 | versions.v00001.process, 28 | versions.v00001.suspender, 29 | versions.v00001.utils, 30 | ] 31 | 32 | mock_subsitutes = { 33 | "genericpath": "os.path", 34 | "posixpath": "os.path", 35 | } 36 | 37 | global tester 38 | tester = auger.magic(test_subjects, mock_substitutes=mock_subsitutes) 39 | tester.__enter__() 40 | 41 | version_manager.main(done) 42 | 43 | def done(): 44 | print "######## Auger testing done" 45 | try: 46 | tester.__exit__(None, None, None) 47 | except: 48 | traceback.print_exc() 49 | 50 | start() -------------------------------------------------------------------------------- /sign.sh: -------------------------------------------------------------------------------- 1 | 2 | echo "###### codesign ###############" 3 | # http://www.manpagez.com/man/1/codesign/ 4 | # https://developer.apple.com/library/archive/technotes/tn2206/_index.html 5 | # https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/Procedures/Procedures.html 6 | # https://forum.xojo.com/49408-10-14-hardened-runtime-and-app-notarization/0 7 | # https://stackoverflow.com/questions/52905940/how-to-codesign-and-enable-the-hardened-runtime-for-a-3rd-party-cli-on-xcode 8 | 9 | 10 | ID="Developer ID Application: LAFFRA JOHANNES (29P9D64BXJ)" 11 | 12 | for filename in $(find dist/happymac.app/ -name "*.dylib"); do 13 | codesign -v -f -s "$ID" $filename 14 | done 15 | for filename in $(find dist/happymac.app/ -name "*.so"); do 16 | codesign -v -f -s "$ID" $filename 17 | done 18 | codesign -v -f -s "$ID" dist/happymac.app/Contents/Frameworks/Python.framework/Versions/2.7/Python 19 | codesign -v -f --entitlements app.entitlements -o runtime -s "$ID" dist/happymac.app/Contents/MacOS/python 20 | codesign -v -f --entitlements app.entitlements -o runtime -s "$ID" dist/happymac.app/Contents/MacOS/happymac 21 | 22 | spctl --assess --type execute dist/happymac.app -------------------------------------------------------------------------------- /src/tests/test_log.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import datetime 3 | import log 4 | from mock import patch 5 | import os 6 | import os.path 7 | import preferences 8 | import process 9 | import psutil 10 | # 11 | # TODO: Fix tests, needs work on Auger's automatic test generator 12 | # 13 | from psutil import Popen 14 | import sys 15 | import unittest 16 | import utils 17 | import versions.v00001.process 18 | import versions.v00001.suspender 19 | from versions.v00001.suspender import defaultdict 20 | import versions.v00001.utils 21 | from versions.v00001.utils import OnMainThread 22 | 23 | 24 | class LogTest(unittest.TestCase): 25 | @patch.object(os.path, 'join') 26 | @patch.object(os.path, 'exists') 27 | def test_get_log_path(self, mock_exists, mock_join): 28 | mock_exists.return_value = True 29 | mock_join.return_value = '/Users/chris/HappyMacApp/downloads/v00001' 30 | self.assertEqual( 31 | log.get_log_path(), 32 | '/Users/chris/HappyMacApp/happymac_log.txt' 33 | ) 34 | 35 | 36 | def test_log(self): 37 | self.assertEqual( 38 | log.log(message='Google process 44784 ()',error=None), 39 | None 40 | ) 41 | 42 | 43 | if __name__ == "__main__": 44 | unittest.main() 45 | -------------------------------------------------------------------------------- /src/log.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | home_dir = os.path.join(os.path.expanduser("~"), "HappyMacApp") 5 | ONE_KB = 1024 6 | ONE_MB = ONE_KB * ONE_KB 7 | 8 | def get_log_path(): 9 | try: 10 | if not os.path.exists(home_dir): 11 | os.makedirs(home_dir) 12 | path = os.path.join(home_dir, "happymac_log.txt") 13 | except: 14 | path = os.path.join(os.path.expanduser("~"), "happymac_log.txt") 15 | if not os.path.exists(path): 16 | with open(path, "w") as output: 17 | output.write("HappyMac Activity Log:\n") 18 | return path 19 | 20 | def log(message, error=None, truncate=True): 21 | line = "%s: %s %s" % (datetime.datetime.utcnow(), message, error or "") 22 | with open(get_log_path(), "a") as output: 23 | output.write(" %s" % line) 24 | output.write("\n") 25 | print(line) 26 | if truncate: 27 | truncate_log() 28 | 29 | def truncate_log(): 30 | size = os.stat(get_log_path()).st_size 31 | if size > ONE_MB: 32 | lines = open(get_log_path()).read().split("\n") 33 | with open(get_log_path(), "w") as output: 34 | output.write("\n".join(lines[-100:])) 35 | log("Truncated output to 100 lines", truncate=False) 36 | 37 | 38 | def get_log(): 39 | with open(get_log_path()) as input: 40 | return input.read() -------------------------------------------------------------------------------- /app/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | English 7 | CFBundleDisplayName 8 | happymac 9 | CFBundleExecutable 10 | happymac 11 | CFBundleGetInfoString 12 | HappyMac App 13 | CFBundleIconFile 14 | app.icns 15 | CFBundleIdentifier 16 | com.chrislaffra.osx.happymac 17 | CFBundleInfoDictionaryVersion 18 | 6.0 19 | CFBundleName 20 | happymac 21 | CFBundlePackageType 22 | APPL 23 | CFBundleShortVersionString 24 | 0.1.0 25 | CFBundleSignature 26 | ???? 27 | CFBundleVersion 28 | 0.1.0 29 | LSHasLocalizedDisplayName 30 | 31 | LSUIElement 32 | 33 | NSAppleScriptEnabled 34 | 35 | NSHumanReadableCopyright 36 | Copyright 2018, Chris Laffra, All Rights Reserved 37 | NSMainNibFile 38 | MainMenu 39 | NSPrincipalClass 40 | NSApplication 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/happymac.app.template/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | English 7 | CFBundleDisplayName 8 | happymac 9 | CFBundleExecutable 10 | happymac 11 | CFBundleGetInfoString 12 | HappyMac App 13 | CFBundleIconFile 14 | app.icns 15 | CFBundleIdentifier 16 | com.chrislaffra.osx.happymac 17 | CFBundleInfoDictionaryVersion 18 | 6.0 19 | CFBundleName 20 | happymac 21 | CFBundlePackageType 22 | APPL 23 | CFBundleShortVersionString 24 | 0.1.0 25 | CFBundleSignature 26 | ???? 27 | CFBundleVersion 28 | 0.1.0 29 | LSHasLocalizedDisplayName 30 | 31 | LSUIElement 32 | 33 | NSAppleScriptEnabled 34 | 35 | NSHumanReadableCopyright 36 | Copyright 2018, Chris Laffra, All Rights Reserved 37 | NSMainNibFile 38 | MainMenu 39 | NSPrincipalClass 40 | NSApplication 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/tests/test_preferences.py: -------------------------------------------------------------------------------- 1 | # 2 | # TODO: Fix tests, needs work on Auger's automatic test generator 3 | # 4 | from collections import defaultdict 5 | import log 6 | from mock import patch 7 | import os 8 | import os.path 9 | import preferences 10 | import process 11 | import psutil 12 | from psutil import Popen 13 | import sys 14 | import unittest 15 | import utils 16 | import versions.v00001.process 17 | import versions.v00001.suspender 18 | from versions.v00001.suspender import defaultdict 19 | import versions.v00001.utils 20 | from versions.v00001.utils import OnMainThread 21 | 22 | 23 | class PreferencesTest(unittest.TestCase): 24 | def test_get(self): 25 | self.assertEqual( 26 | preferences.get(default=None,key='suspend - Electron'), 27 | None 28 | ) 29 | 30 | 31 | @patch.object(os.path, 'expanduser') 32 | @patch.object(os.path, 'join') 33 | @patch.object(os.path, 'exists') 34 | def test_get_preferences_path(self, mock_exists, mock_join, mock_expanduser): 35 | mock_exists.return_value = True 36 | mock_join.return_value = '/Users/chris/HappyMacApp/downloads/v00001' 37 | mock_expanduser.return_value = '/Users/chris' 38 | self.assertEqual( 39 | preferences.get_preferences_path(), 40 | '/Users/chris/HappyMacApp/happymac.prefs' 41 | ) 42 | 43 | 44 | @patch.object(log, 'log') 45 | def test_set(self, mock_log): 46 | mock_log.return_value = None 47 | self.assertEqual( 48 | preferences.set(value=True,key='suspend - qemu-system-i386'), 49 | None 50 | ) 51 | 52 | 53 | if __name__ == "__main__": 54 | unittest.main() 55 | -------------------------------------------------------------------------------- /src/tests/test_version_manager.py: -------------------------------------------------------------------------------- 1 | # 2 | # TODO: Fix tests, needs work on Auger's automatic test generator 3 | # 4 | import StringIO 5 | import abc 6 | from abc import ABCMeta 7 | import collections 8 | from collections import OrderedDict 9 | from collections import defaultdict 10 | import datetime 11 | import error 12 | import functools 13 | import gc 14 | import glob 15 | import imp 16 | import inspect 17 | import install 18 | import json 19 | import license 20 | import log 21 | from mock import patch 22 | import os 23 | import os.path 24 | import preferences 25 | import process 26 | import psutil 27 | from psutil import Popen 28 | import requests 29 | import rumps 30 | import rumps.rumps 31 | from rumps.rumps import App 32 | import suspender 33 | import sys 34 | import tempfile 35 | import time 36 | import unittest 37 | import utils 38 | import version_manager 39 | import versions 40 | import versions.v00001.main 41 | from versions.v00001.main import HappyMacStatusBarApp 42 | import versions.v00001.process 43 | import versions.v00001.suspender 44 | from versions.v00001.suspender import defaultdict 45 | import versions.v00001.utils 46 | from versions.v00001.utils import OnMainThread 47 | import webbrowser 48 | from webbrowser import BackgroundBrowser 49 | 50 | 51 | class Version_managerTest(unittest.TestCase): 52 | @patch.object(os.path, 'join') 53 | @patch.object(os.path, 'exists') 54 | @patch.object(log, 'log') 55 | def test_find_downloaded_version(self, mock_log, mock_exists, mock_join): 56 | mock_log.return_value = None 57 | mock_exists.return_value = True 58 | mock_join.return_value = '/Users/chris/HappyMacApp/downloads/v00001' 59 | self.assertEqual( 60 | version_manager.find_downloaded_version(version='v00001'), 61 | None 62 | ) 63 | 64 | 65 | @patch.object(log, 'log') 66 | def test_find_version(self, mock_log): 67 | mock_log.return_value = None 68 | self.assertIsInstance( 69 | version_manager.find_version(version='v00001'), 70 | __builtin__.module 71 | ) 72 | 73 | 74 | def test_last_version(self): 75 | self.assertEqual( 76 | version_manager.last_version(), 77 | 'v00001' 78 | ) 79 | 80 | 81 | if __name__ == "__main__": 82 | unittest.main() 83 | -------------------------------------------------------------------------------- /src/versions/v00001/suspender.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import log 3 | import os 4 | import process 5 | import preferences 6 | import utils 7 | 8 | suspended_tasks = set() 9 | 10 | ACTIVATE_CURRENT_APP = """ 11 | set currentApp to path to frontmost application 12 | delay 3 13 | activate currentApp 14 | """ 15 | SUSPEND_ALWAYS = "always" 16 | SUSPEND_ON_BATTERY = "battery" 17 | 18 | last_activated_pid = 0 19 | 20 | def manage(foregroundTasks, backgroundTasks): 21 | for task in foregroundTasks: 22 | if not process.is_system_process(task.pid): 23 | resume_process(task.pid) 24 | if not process.on_battery(): 25 | suspended_pids = [pid for pid,_ in suspended_tasks] 26 | for pid in filter(lambda pid: get_suspend_preference(pid) == SUSPEND_ON_BATTERY, suspended_pids): 27 | resume_process(pid) 28 | for task in filter(lambda task: get_suspend_preference(task.pid), backgroundTasks): 29 | if process.is_system_process(task.pid): 30 | continue 31 | suspend_task_on_battery = get_suspend_preference(task.pid) == SUSPEND_ON_BATTERY 32 | if suspend_task_on_battery and not process.on_battery(): 33 | continue 34 | suspend_process(task.pid, battery=suspend_task_on_battery) 35 | 36 | def activate_current_app(): 37 | global last_activated_pid 38 | if not utils.get_current_app(): 39 | return 40 | pid = utils.get_current_app_pid() 41 | if pid != -1 and pid != last_activated_pid: 42 | os.system("osascript -e \"%s\" &" % ACTIVATE_CURRENT_APP) 43 | last_activated_pid = pid 44 | 45 | def suspend_process(pid, manual=False, battery=False): 46 | name = process.get_name(pid) 47 | if manual: 48 | set_suspend_preference(name, SUSPEND_ON_BATTERY if battery else SUSPEND_ALWAYS) 49 | if battery and not process.on_battery(): 50 | return 51 | if process.suspend_pid(pid): 52 | suspended_tasks.add((pid, name)) 53 | else: 54 | set_suspend_preference(name, "") 55 | 56 | def resume_process(pid, manual=False): 57 | name = process.get_name(pid) 58 | if manual or (pid,name) in suspended_tasks: 59 | if process.resume_pid(pid): 60 | for pid, suspended_name in list(suspended_tasks): 61 | if name == suspended_name: 62 | suspended_tasks.remove((pid, name)) 63 | if manual: 64 | set_suspend_preference(name, "") 65 | 66 | def set_suspend_preference(name, value): 67 | preferences.set("suspend - %s" % name, value) 68 | 69 | def get_suspend_preference(pid): 70 | return preferences.get("suspend - %s" % process.get_name(pid)) 71 | 72 | def get_suspended_tasks(): 73 | return [process.get_process(pid) for pid,_ in suspended_tasks] 74 | 75 | def exit(): 76 | for pid,_ in suspended_tasks: 77 | process.resume_pid(pid) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | src/last.py 2 | 3 | # from https://github.com/github/gitignore/blob/master/Python.gitignore 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # pytype static type analyzer 139 | .pytype/ 140 | 141 | # Cython debug symbols 142 | cython_debug/ 143 | 144 | # pycharm 145 | .idea 146 | 147 | # apple 148 | .DS_Store 149 | -------------------------------------------------------------------------------- /src/error.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import log 3 | import os 4 | import platform 5 | import rumps 6 | import traceback 7 | 8 | home_dir = os.path.join(os.path.expanduser("~"), "HappyMacApp") 9 | 10 | def get_system_info(): 11 | return """ 12 | System Details: 13 | 14 | Version: %s 15 | Compiler: %s 16 | Build: %s 17 | Platform: %s 18 | System: %s 19 | Node: %s 20 | Release: %s 21 | Version: %s 22 | 23 | """ % ( 24 | platform.python_version(), 25 | platform.python_compiler(), 26 | platform.python_build(), 27 | platform.platform(), 28 | platform.system(), 29 | platform.node(), 30 | platform.release(), 31 | platform.version(), 32 | ) 33 | 34 | def get_home_dir_info(): 35 | return "\nHappyMac Home Folder:\n%s\n\n" % "\n".join([ 36 | " %s" % os.path.join(root, filename) 37 | for root, _, filenames in os.walk(home_dir) 38 | for filename in filenames 39 | ]) 40 | 41 | def get_preferences(): 42 | try: 43 | import preferences 44 | import json 45 | if preferences.preferences: 46 | return "HappyMac Preferences:\n%s\n\n" % json.dumps(preferences.preferences, indent=4) 47 | except: 48 | return "" 49 | 50 | def get_versions(): 51 | try: 52 | import version_manager 53 | return "HappyMac Available Versions:\n%s\n\n" % version_manager.get_versions() 54 | except: 55 | return "" 56 | 57 | def get_error_file_path(): 58 | try: 59 | error_dir = os.path.join(os.path.join(home_dir, "errors")) 60 | if not os.path.exists(error_dir): 61 | os.makedirs(error_dir) 62 | path = os.path.join(os.path.join(error_dir, "happymac_error-%s.txt" % datetime.datetime.utcnow())) 63 | except: 64 | path = os.path.join(os.path.expanduser("~"), "happymac_error.txt") 65 | return path.replace(' ', '_') 66 | 67 | def error(message): 68 | stack = "HappyMac Execution Stack at Error Time:\n%s\n" % "".join(traceback.format_stack()[:-1]) 69 | exception = "HappyMac Exception:\n %s\n" % traceback.format_exc() 70 | error = "HappyMac Error:\n %s\n%s%s%s%s%s\n%s%sHappyMac Error:\n %s\n" % ( 71 | message, 72 | get_system_info(), 73 | get_home_dir_info(), 74 | get_preferences(), 75 | get_versions(), 76 | log.get_log(), 77 | stack, 78 | exception, 79 | message 80 | ) 81 | path = get_error_file_path() 82 | try: 83 | with open(path, "w") as output: 84 | output.write("HappyMac Error Report - %s\n\n" % datetime.datetime.utcnow()) 85 | os.system("system_profiler SPHardwareDataType >> %s" % path) 86 | with open(path, "a") as output: 87 | output.write(error) 88 | with open(path) as input: 89 | print(input.read()) 90 | except: 91 | pass 92 | log.log(error) 93 | rumps.notification("HappyMac", "Error: %s. For details see:" % message, path, sound=True) -------------------------------------------------------------------------------- /src/version.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import inspect 3 | import re 4 | import os 5 | from versions import v00001 6 | 7 | names = [ "main", "install", "process", "suspender", "utils" ] 8 | contents = "" 9 | short_names = {} 10 | 11 | SHORTEN_NAME = False 12 | LOCAL_TEST = False 13 | 14 | if LOCAL_TEST: 15 | SEPARATOR = "\n" 16 | DEST_DIR = "src" 17 | else: 18 | SEPARATOR = "@@@" 19 | DEST_DIR = "/tmp" 20 | 21 | def shorten(name): 22 | if not SHORTEN_NAME: 23 | return name 24 | if name in short_names: 25 | return short_names[name] 26 | short_names[name] = "h%d" % len(short_names) 27 | return short_names[name] 28 | 29 | for name in names: 30 | contents += "#" * 30 + "# %s\n\n" % name 31 | source = open("src/versions/v00001/%s.py" % name).read() 32 | mod = getattr(v00001, name) 33 | for key in reversed(sorted(dir(mod))): 34 | item = getattr(mod, key) 35 | if SHORTEN_NAME and key.upper() == key: 36 | source = source.replace(key, "%s" % shorten(key)) 37 | if name != "main": 38 | if inspect.isfunction(item): 39 | func_name = item.__name__ 40 | if func_name[0] == "_": 41 | continue 42 | source = source.replace("%s(" % func_name, "%s_%s(" % (shorten(name), shorten(func_name))) 43 | source = source.replace("map(%s," % func_name, "map(%s_%s," % (shorten(name), shorten(func_name))) 44 | source = source.replace(" = %s" % func_name, " = %s_%s" % (shorten(name), shorten(func_name))) 45 | contents += source + "\n" 46 | 47 | for name in names: 48 | if name == "main": 49 | continue 50 | contents = contents.replace("import %s\n" % name, "") 51 | mod = getattr(v00001, name) 52 | for key in dir(mod): 53 | item = getattr(mod, key) 54 | if inspect.isfunction(item): 55 | func_name = item.__name__ 56 | if func_name[0] == "_": 57 | continue 58 | contents = contents.replace("def %s(" % func_name, "def %s_%s(" % (shorten(name), shorten(func_name))) 59 | contents = contents.replace("%s.%s(" % (name, func_name), "%s_%s(" % (shorten(name), shorten(func_name))) 60 | contents = contents.replace("%s.%s," % (name, func_name), "%s_%s," % (shorten(name), shorten(func_name))) 61 | elif name == "utils" and inspect.isclass(item): 62 | class_name = item.__name__ 63 | if class_name == "Timer": 64 | contents = contents.replace("class %s(" % class_name, "class %s_%s(" % (shorten(name), shorten(class_name))) 65 | contents = contents.replace("%s.%s(" % (name, class_name), "%s_%s(" % (shorten(name), shorten(class_name))) 66 | contents = contents.replace("super(%s," % class_name, "super(%s_%s," % (shorten(name), shorten(class_name))) 67 | 68 | with open("%s/last.py" % DEST_DIR, "w") as fout: 69 | if SEPARATOR != "\n": 70 | contents = contents.replace("\n", SEPARATOR) 71 | fout.write(contents) 72 | fout.write("""if __name__ == "__main__":%s run()%s""" % (SEPARATOR, SEPARATOR)) 73 | 74 | os.system(r"/Applications/Visual\ Studio\ Code.app/Contents/Resources/app/bin/code %s/last.py" % DEST_DIR) 75 | -------------------------------------------------------------------------------- /src/version_manager.py: -------------------------------------------------------------------------------- 1 | #pylint: disable=E0401 2 | 3 | import error 4 | import imp 5 | import log 6 | import os 7 | import glob 8 | import inspect 9 | import json 10 | import preferences 11 | import requests 12 | import rumps 13 | import sys 14 | import tempfile 15 | import traceback 16 | import uuid 17 | import versions 18 | 19 | try: 20 | import cPickle as pickle 21 | except ImportError: 22 | import pickle as pickle 23 | 24 | home_dir = os.path.join(os.path.expanduser("~"), "HappyMacApp") 25 | downloads_dir = os.path.join(home_dir, "downloads") 26 | if not os.path.exists(downloads_dir): 27 | os.makedirs(downloads_dir) 28 | 29 | sys.path.append(home_dir) 30 | running_local = not getattr(sys, "_MEIPASS", False) 31 | testing = False 32 | 33 | 34 | def main(quit_callback=None): 35 | if testing or not running_local: 36 | download_latest() 37 | try: 38 | load_version(last_version(), quit_callback) 39 | except Exception as e: 40 | log.log("ERROR: Could not load version due to %s. Loading built-in v00001" % e) 41 | log.log(traceback.format_exc()) 42 | load_version("v00001", quit_callback) 43 | 44 | def load_version(version, quit_callback=None): 45 | log.log("Load version %s" % version) 46 | try: 47 | mod = find_version(version) 48 | except Exception as e: 49 | log.log("Could not find version %s due to %s" % (version, e)) 50 | mod = find_version(last_version()) 51 | if mod: 52 | main_mod = getattr(mod, "main") 53 | log.log("Calling run on %s" % main_mod) 54 | main_mod.run(quit_callback) 55 | else: 56 | log.log("Cannot load version %s" % version) 57 | 58 | def find_version(version): 59 | log.log("Find version %s" % version) 60 | return getattr(versions, version, find_downloaded_version(version)) 61 | 62 | def find_downloaded_version(version): 63 | version_path = os.path.join(downloads_dir, '%s' % version) 64 | if not os.path.exists(version_path): 65 | log.log("Downloads: Could not find version %s in %s" % (version, version_path)) 66 | return None 67 | try: 68 | with open(version_path, "rb") as file: 69 | package = pickle.load(file) 70 | mod = load_module_from_source(version, package["contents"]) 71 | mod.main = mod 72 | return mod 73 | except Exception as e: 74 | error.error("Download: Problem with version %s: %s" % (version, e)) 75 | 76 | def load_module_from_source(module_name, source): 77 | temporary_path = tempfile.mkstemp(".py")[1] 78 | with open(temporary_path, "w") as fout: 79 | fout.write(source) 80 | mod = imp.load_source(module_name, temporary_path) 81 | if not testing: 82 | os.remove(temporary_path) 83 | return mod 84 | 85 | def download_latest(): 86 | try: 87 | hardware_uuid = uuid.get_hardware_uuid() 88 | latest_url = 'https://happymac.app/_functions/latest?version=%s&uuid=%s' % (last_version(), hardware_uuid) 89 | log.log("Download: getting the latest version at %s" % latest_url) 90 | latest = json.loads(requests.get(latest_url).content) 91 | latest['contents'] = latest['contents'].replace("@@@", "\n") 92 | save_contents(latest) 93 | except: 94 | error.error("Download: cannot get latest version") 95 | 96 | def save_contents(latest): 97 | version = latest["version"] 98 | path = os.path.join(downloads_dir, '%s' % version) 99 | if os.path.exists(path): 100 | log.log("Download: version %s already installed" % version) 101 | else: 102 | with open(path, "wb") as file: 103 | pickle.dump(latest, file, 2) 104 | log.log("Download: extracted version %s to %s" % (version, path)) 105 | rumps.notification("HappyMac Update", "A new version was downloaded", "Running %s" % version, sound=False) 106 | log.log("Download: available versions: %s" % get_versions()) 107 | 108 | def last_version(): 109 | if not testing and running_local: 110 | return "v00001" 111 | return sorted(get_versions())[-1] 112 | 113 | def get_versions(): 114 | available_builtin_versions = filter(inspect.ismodule, [ getattr(versions, name) for name in dir(versions) ]) 115 | builtin_versions = [version.__name__.split(".")[-1] for version in available_builtin_versions] 116 | available_downloaded_versions = glob.glob(os.path.join(downloads_dir, "v[0-9]*")) 117 | downloaded_versions = [version.split(os.path.sep)[-1] for version in available_downloaded_versions] 118 | return sorted(builtin_versions + downloaded_versions) 119 | 120 | if __name__ == "__main__": 121 | testing = True 122 | main() -------------------------------------------------------------------------------- /src/tests/test_versions_v00001_utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # TODO: Fix tests, needs work on Auger's automatic test generator 3 | # 4 | import AppKit 5 | import Foundation 6 | import Quartz 7 | from Quartz import CG 8 | from Quartz import CoreGraphics 9 | import StringIO 10 | import abc 11 | from abc import ABCMeta 12 | import collections 13 | from collections import OrderedDict 14 | from collections import defaultdict 15 | import datetime 16 | import error 17 | import functools 18 | import gc 19 | import glob 20 | import imp 21 | import inspect 22 | import install 23 | import json 24 | import license 25 | import log 26 | from mock import patch 27 | import objc 28 | import objc._convenience 29 | import objc._convenience_mapping 30 | from objc._convenience_mapping import selector 31 | import objc._lazyimport 32 | from objc._lazyimport import ObjCLazyModule 33 | import os 34 | import os.path 35 | import preferences 36 | import process 37 | import psutil 38 | from psutil import Popen 39 | import requests 40 | import rumps 41 | import rumps.rumps 42 | from rumps.rumps import App 43 | import struct 44 | import suspender 45 | import sys 46 | import tempfile 47 | import threading 48 | import time 49 | import traceback 50 | import unittest 51 | import utils 52 | import version_manager 53 | import versions 54 | import versions.v00001.main 55 | from versions.v00001.main import HappyMacStatusBarApp 56 | import versions.v00001.process 57 | import versions.v00001.suspender 58 | from versions.v00001.suspender import defaultdict 59 | import versions.v00001.utils 60 | from versions.v00001.utils import OnMainThread 61 | from versions.v00001.utils import Timer 62 | import webbrowser 63 | from webbrowser import BackgroundBrowser 64 | 65 | 66 | class UtilsTest(unittest.TestCase): 67 | @patch.object(App, '_nsimage_from_file') 68 | def test__nsimage_from_file(self, mock__nsimage_from_file): 69 | mock__nsimage_from_file.return_value = CGImageSource=0x7fd61170f2c0" 71 | )> 72 | self.assertEqual( 73 | versions.v00001.utils._nsimage_from_file(path='/Users/chris/dev/happymac/icons/happy-transparent.png',dimensions=None,template=None), 74 | CGImageSource=0x7fd60ef8cb70" 76 | )> 77 | ) 78 | 79 | 80 | def test_clear_windows_cache(self): 81 | self.assertEqual( 82 | versions.v00001.utils.clear_windows_cache(), 83 | None 84 | ) 85 | 86 | 87 | @patch.object(ObjCLazyModule, '__getattr__') 88 | @patch.object(objc._convenience, 'add_convenience_methods') 89 | def test_get_current_app(self, mock_add_convenience_methods, mock___getattr__): 90 | mock_add_convenience_methods.return_value = None 91 | mock___getattr__.return_value = NSWorkspace() 92 | self.assertEqual( 93 | versions.v00001.utils.get_current_app(), 94 | { 95 | NSApplicationBundleIdentifier = "org.python.python"; 96 | NSApplicationName = Python; 97 | NSApplicationPath = "/Library/Frameworks/Python.framework/Versions/2.7/Resources/Python.app"; 98 | NSApplicationProcessIdentifier = 44191; 99 | NSApplicationProcessSerialNumberHigh = 0; 100 | NSApplicationProcessSerialNumberLow = 1466726; 101 | NSWorkspaceApplicationKey = ""; 102 | } 103 | ) 104 | 105 | 106 | @patch.object(objc._convenience_mapping, '__getitem__objectForKey_') 107 | def test_get_current_app_pid(self, mock___getitem__objectForKey_): 108 | mock___getitem__objectForKey_.return_value = __NSCFNumber() 109 | self.assertIsInstance( 110 | versions.v00001.utils.get_current_app_pid(), 111 | objc.__NSCFNumber 112 | ) 113 | 114 | 115 | def test_initWithCallback_(self): 116 | onmainthread_instance = OnMainThread() 117 | self.assertIsInstance( 118 | onmainthread_instance.initWithCallback_, 119 | versions.v00001.utils.OnMainThread 120 | ) 121 | 122 | 123 | @patch.object(HappyMacStatusBarApp, 'update') 124 | def test_run_(self, mock_update): 125 | mock_update.return_value = None 126 | onmainthread_instance = OnMainThread() 127 | self.assertEqual( 128 | onmainthread_instance.run_, 129 | None 130 | ) 131 | 132 | 133 | if __name__ == "__main__": 134 | unittest.main() 135 | -------------------------------------------------------------------------------- /src/tests/test_versions_v00001_suspender.py: -------------------------------------------------------------------------------- 1 | # 2 | # TODO: Fix tests, needs work on Auger's automatic test generator 3 | # 4 | from collections import defaultdict 5 | import log 6 | from mock import patch 7 | import os 8 | import preferences 9 | import process 10 | import psutil 11 | from psutil import Popen 12 | import unittest 13 | import utils 14 | import versions.v00001.process 15 | import versions.v00001.suspender 16 | from versions.v00001.suspender import defaultdict 17 | import versions.v00001.utils 18 | from versions.v00001.utils import OnMainThread 19 | 20 | 21 | class SuspenderTest(unittest.TestCase): 22 | @patch.object(Popen, 'pid') 23 | def test_(self, mock_pid): 24 | mock_pid.return_value = 508 25 | self.assertEqual( 26 | versions.v00001.suspender.(task=psutil.Process(pid=25779, name='CrashPlanService', started='14:08:58')), 27 | True 28 | ) 29 | 30 | 31 | @patch.object(versions.v00001.utils, 'get_current_app_pid') 32 | def test_activate_current_app(self, mock_get_current_app_pid): 33 | mock_get_current_app_pid.return_value = __NSCFNumber() 34 | self.assertEqual( 35 | versions.v00001.suspender.activate_current_app(), 36 | None 37 | ) 38 | 39 | 40 | @patch.object(versions.v00001.process, 'resume_pid') 41 | def test_exit(self, mock_resume_pid): 42 | mock_resume_pid.return_value = True 43 | self.assertEqual( 44 | versions.v00001.suspender.exit(), 45 | None 46 | ) 47 | 48 | 49 | @patch.object(versions.v00001.process, 'get_name') 50 | @patch.object(preferences, 'get') 51 | def test_get_suspend_preference(self, mock_get, mock_get_name): 52 | mock_get.return_value = None 53 | mock_get_name.return_value = 'Python' 54 | self.assertEqual( 55 | versions.v00001.suspender.get_suspend_preference(pid=611), 56 | None 57 | ) 58 | 59 | 60 | @patch.object(versions.v00001.process, 'get_process') 61 | def test_get_suspended_tasks(self, mock_get_process): 62 | mock_get_process.return_value = Process() 63 | self.assertEqual( 64 | versions.v00001.suspender.get_suspended_tasks(), 65 | [psutil.Process(pid=87, name='CbOsxSensorServi', started='2018-11-20 00:01:50'), psutil.Process(pid=25779, name='CrashPlanService', started='14:08:58')] 66 | ) 67 | 68 | 69 | @patch.object(Popen, 'pid') 70 | @patch.object(versions.v00001.process, 'is_system_process') 71 | def test_manage(self, mock_is_system_process, mock_pid): 72 | mock_is_system_process.return_value = False 73 | mock_pid.return_value = 508 74 | self.assertEqual( 75 | versions.v00001.suspender.manage(foregroundTasks=[psutil.Process(pid=32838, name='vsls-agent', started='15:44:35'), psutil.Process(pid=1, name='launchd', started='2018-11-20 00:01:42'), psutil.Process(pid=33509, name='Python', started='15:52:08'), psutil.Process(pid=33514, name='Python', started='15:52:08'), psutil.Process(pid=32263, name='bash', started='15:44:26'), psutil.Process(pid=32584, name='Code Helper', started='15:44:30'), psutil.Process(pid=32301, name='Code Helper', started='15:44:26'), psutil.Process(pid=467, name='Code Helper', started='2018-11-20 00:02:35'), psutil.Process(pid=32262, name='Code Helper', started='15:44:26'), psutil.Process(pid=436, name='Code Helper', started='2018-11-20 00:02:27'), psutil.Process(pid=395L, name='Electron', started='2018-11-20 00:02:24'), psutil.Process(pid=32261, name='Code Helper', started='15:44:24'), psutil.Process(pid=44191, name='Python', started='15:59:46')],backgroundTasks=[psutil.Process(pid=374, name='Google Chrome', started='2018-11-20 00:02:22'), psutil.Process(pid=159, name='coreaudiod', started='2018-11-20 00:01:50'), psutil.Process(pid=168, name='WindowServer', started='2018-11-20 00:01:51'), psutil.Process(pid=1489, name='com.docker.hyperkit', started='2018-11-20 00:03:14'), psutil.Process(pid=64674, status='terminated')]), 76 | None 77 | ) 78 | 79 | 80 | @patch.object(versions.v00001.process, 'get_name') 81 | @patch.object(versions.v00001.process, 'resume_pid') 82 | def test_resume_process(self, mock_resume_pid, mock_get_name): 83 | mock_resume_pid.return_value = True 84 | mock_get_name.return_value = 'Python' 85 | self.assertEqual( 86 | versions.v00001.suspender.resume_process(manual=False,pid=38310), 87 | None 88 | ) 89 | 90 | 91 | @patch.object(preferences, 'set') 92 | def test_set_suspend_preference(self, mock_set): 93 | mock_set.return_value = None 94 | self.assertEqual( 95 | versions.v00001.suspender.set_suspend_preference(name='com.docker.hyperkit',value=False), 96 | None 97 | ) 98 | 99 | 100 | @patch.object(versions.v00001.process, 'get_name') 101 | @patch.object(versions.v00001.process, 'suspend_pid') 102 | def test_suspend_process(self, mock_suspend_pid, mock_get_name): 103 | mock_suspend_pid.return_value = True 104 | mock_get_name.return_value = 'Python' 105 | self.assertEqual( 106 | versions.v00001.suspender.suspend_process(manual=False,pid=64674), 107 | None 108 | ) 109 | 110 | 111 | if __name__ == "__main__": 112 | unittest.main() 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HappyMac 2 | 3 | 4 | 5 | HappyMac features: 6 | - Automatically suspends background processes 7 | - Stops your CPU from heating up 8 | - Preserves battery life 9 | - Makes your Mac happy again 10 | 11 | # Installation 12 | 13 | Three ways to install HappyMac: 14 | - Visit [happymac.app](https://happymac.app) for a ready to install DMG of HappyMac. 15 | - Clone this repo and run from the commandline (see the bottom of this README). 16 | - Use: `brew cask install happymac` (see: https://formulae.brew.sh/cask/happymac for more info). 17 | 18 | # How it works 19 | HappyMac is a status bar app for Mac with an icon showing in the status bar. The icon itself changes from *happy* to *unhappy* to *sweating* to *mad*, depending on the current load of your Mac. In the menu above, the machine is showing as *sweating*, as the overall CPU usage on the machine, including all available cores, is currently in between 51% and 75%. 20 | 21 | The menu shows three categories of processes: 22 | - **Current App Tasks**: These are the processes that make up the *process* *family* of the currently active application. This includes the process that created the currently active window and all its parent processes up to the Launch process. Also included are all the child processes of the current process, their child processes, etc. 23 | 24 | - **Background Tasks**: These are tasks that use up CPU even though they run in the background. Literally hundreds of tasks normally run in the background and they only become troublesome when they run hot. 25 | 26 | At any point in time you can decide to suspend any background task using the context menu, as is shown above. What happens is that the given task is suspended and moved to the suspended tasks. HappyMac also remembers this decision and will make sure the process is suspended any time it is not part of the foreground task family. 27 | 28 | - **Suspended Background Tasks**: Suspended tasks are those that are currently suspended by HappyMac. You can resume a suspended task by bringing the process to the foreground, assuming it has a UI. You can also manually resume a task using the context menu. 29 | 30 | ### HappyMac Logic 31 | 32 | Once a suspended process is activated, by using Cmd+Tab for instance, all the processes in its family are resumed. The logic for suspending and resuming is currently hardwired into HappyMac. 33 | 34 | To remove noise, active processes that use up less than 3% of CPU are not shown. Unlike the overall CPU represented by the status bar icon, a given task can use more than 100% of CPU. This is the case when a task uses more than one core of the machine. Most Macbooks have 4 cores and some even 6. It is quite uncommon for a process to use more than 100% of the CPU. Few processes use more than 2 cores. 35 | 36 | Just like the task bar icon, each individual task gets a different emoticon for each percentage of CPU it uses. The same scale is used. A happy process uses 25% CPU or less (of one core), an unhappy 50% or less, a sweating one 75% or less, and a mad one more than 75%. 37 | 38 | ### A Practical Example 39 | 40 | Say we are developing an Android app and start up the Intellij IDE. From it, we launch the Android emulator. CPU goes up, as we can tell as it will not take long for the Macbook's fan to turn on. When we switch to another application, both the **idea** and **qemu-system-i386** tasks keep using CPU. 41 | 42 | Together, they use up more than two cores. Eventually, **idea** will slow down and not use any CPU anymore, but the second task (which is the Android emulator) keeps on using CPU, even when it is "not doing anything". With HappyMac, such processes can be automatically suspended. 43 | 44 | ### Admin Tasks 45 | 46 | Sometimes, you may want to suspend a process that does not belong to your current user account. In such a case, HappyMac will ask you to provide an admin or root password for your machine, so it can "sudo" suspend the process. The dialogue will look like this: 47 | 48 | 49 | 50 | ### Critical Tasks 51 | 52 | Some Mac tasks are part of the operating system and have a critical function. One such example is *WindowServer*. It is the process that draws your display and handles events. If you suspend that process, the only recovery would be to shut down your mac by pressing the power button for 5 seconds. Processes like WindowServer will not be suspended by HappyMac: 53 | 54 | 55 | 56 | ### Terminating Tasks 57 | 58 | The context menu on processes listed in HappyMac have a menu item to **Google** for the meaning of the task. In addition to that, it also has a **Terminate** menu item. We strongly recommend you do not terminate processes. HappyMac will show a warning and ask you to confirm you really want to terminate the process, rather than suspend it. 59 | 60 | ### Implementation 61 | 62 | HappyMac is written in Python and uses [psutil](https://pypi.org/project/psutil/) to list all processes every two seconds, compute their CPU, compute the current foreground task family and resume it, if needed. Any previously suspended process that is now running in the background will be suspended automatically as well. If the status bar menu is open, it is redrawn to show the current state. 63 | 64 | To draw the status bar menu and the dialogs shown above, [rumps](https://github.com/jaredks/rumps) is used. 65 | 66 | The distribution, i.e., the DMG file, is created using [PyInstaller](http://www.pyinstaller.org/). 67 | 68 | ### Development 69 | 70 | Want to contribute? Great! Simply download this git repo and send a pull request. 71 | 72 | ### What is next? 73 | 74 | It would be great to add more refined policies for suspending processes. Policies to consider: 75 | 76 | - Only suspend a background process when it uses more than 35% 77 | - Only suspend process X when I am on battery 78 | - When doing a build, don't do backups 79 | - When I am tethered to my phone, suspend process that use the network for more than X MB per minute 80 | - When the CPU is below 30% for a while, it is OK to run backups 81 | 82 | ### Rules of Engagement 83 | 84 | When you actually run HappyMac, be aware you agree with the [Privacy Policy](https://happymac.app/privacy) 85 | and [Terms & Conditions](https://happymac.app/eula). 86 | 87 | Furthermore, please make sure you are in compliance with your company's IT policy when using HappyMac on your work laptop. 88 | 89 | ### License 90 | MIT 91 | 92 | 93 | ### Build HappyMac Yourself 94 | 95 | You can build HappyMac yourself, or run "python src/happymac.py" from a local repo. 96 | 97 | However, for daily use, we suggest you visit [happymac.app](https://happymac.app) and download the ready to install DMG of HappyMac. 98 | -------------------------------------------------------------------------------- /src/versions/v00001/utils.py: -------------------------------------------------------------------------------- 1 | #pylint: disable=E1101 2 | #pylint: disable=E0611 3 | 4 | import AppKit 5 | import collections 6 | import error 7 | import Foundation 8 | import log 9 | import os 10 | import re 11 | import sys 12 | import objc 13 | import process 14 | import psutil 15 | import Quartz 16 | from Quartz import CG 17 | from Quartz import CoreGraphics 18 | import rumps 19 | import struct 20 | import time 21 | import subprocess 22 | import threading 23 | import traceback 24 | 25 | all_windows = None 26 | menu_is_open = False 27 | 28 | NO_APP = { 29 | "NSApplicationName": "", 30 | "NSApplicationBundleIdentifier": "", 31 | "NSApplicationProcessIdentifier": -1, 32 | } 33 | 34 | def set_menu_open(value): 35 | global menu_is_open 36 | menu_is_open = value 37 | 38 | def is_menu_open(): 39 | return menu_is_open 40 | 41 | def get_current_app(): 42 | return AppKit.NSWorkspace.sharedWorkspace().activeApplication() or NO_APP 43 | 44 | def get_current_app_name(): 45 | return get_current_app()["NSApplicationName"] 46 | 47 | def get_current_app_short_name(): 48 | name = get_current_app().get("NSApplicationBundleIdentifier", "???.%s" % get_current_app_name()).split(".")[-1] 49 | return name[0].capitalize() + name[1:] 50 | 51 | def get_current_app_pid(): 52 | return get_current_app()["NSApplicationProcessIdentifier"] 53 | 54 | def get_active_chrome_tabs(): 55 | return [window for window in get_all_windows() if is_chrome_window(window)] 56 | 57 | def get_active_window_name(): 58 | return get_window_name(get_current_app_pid()) 59 | 60 | def dark_mode(): 61 | return os.popen("defaults read -g AppleInterfaceStyle 2>/dev/null").read() 62 | 63 | def get_active_window_dimensions(): 64 | return get_window_dimensions(get_current_app_pid()) 65 | 66 | def get_screen_pixel(x, y): 67 | image = CG.CGWindowListCreateImage( 68 | CoreGraphics.CGRectMake(x, y, 2, 2), 69 | CG.kCGWindowListOptionOnScreenOnly, 70 | CG.kCGNullWindowID, 71 | CG.kCGWindowImageDefault) 72 | bytes = CG.CGDataProviderCopyData(CG.CGImageGetDataProvider(image)) 73 | b, g, r, a = struct.unpack_from("BBBB", bytes, offset=0) 74 | return (r, g, b, a) 75 | 76 | def is_chrome_window(window): 77 | return is_active_window(window) and window.valueForKey_('kCGWindowOwnerName') == "Google Chrome" 78 | 79 | def is_active_window(window, pid=None): 80 | if pid and window.valueForKey_('kCGWindowOwnerPID') != pid: 81 | return False 82 | return window.valueForKey_('kCGWindowIsOnscreen') and window.valueForKey_('kCGWindowName') 83 | 84 | def get_window_name(pid): 85 | windows = [window for window in get_all_windows() if is_active_window(window, pid)] 86 | return windows and windows[0].get('kCGWindowName', '') or '' 87 | 88 | def get_window_dimensions(pid): 89 | windows = [window for window in get_all_windows() if is_active_window(window, pid)] 90 | if windows: 91 | bounds = windows[0].get('kCGWindowBounds') 92 | return (bounds['X'], bounds['Y'], bounds['Width'], bounds['Height']) 93 | return (0,0,0,0) 94 | 95 | def clear_windows_cache(): 96 | global all_windows 97 | all_windows = None 98 | 99 | def get_all_windows(): 100 | global all_windows 101 | if not all_windows: 102 | all_windows = Quartz.CGWindowListCopyWindowInfo(Quartz.kCGWindowListExcludeDesktopElements, Quartz.kCGNullWindowID) 103 | for window in all_windows: 104 | if False and window.valueForKey_('kCGWindowIsOnscreen') : 105 | print(window) 106 | return all_windows 107 | 108 | def run_osa_script(script): 109 | os.system("osascript -e '%s' &" % script) 110 | 111 | def get_auto_release_pool(): 112 | return Quartz.NSAutoreleasePool.alloc().init() 113 | 114 | def on_ethernet(): 115 | try: 116 | interface = get_line(['route', '-n', 'get', 'default'], ["interface"]).split(' ')[-1] 117 | # MacOS likes to enumerate adapters if it has seen the same model before. 118 | # For instance, `Ethernet` the first time and then `Ethernet 1` afterwards. 119 | device = get_line( 120 | ['networksetup', 'listnetworkserviceorder'], 121 | [r'ethernet(,|\s\d+,)', r'lan(,|\s\d+,)', r'ethernet adapter(,|\s\d+,)', r'ethernet slot\s\d+,'] 122 | ).split(' ')[-1] 123 | return interface in device 124 | except: 125 | return False 126 | 127 | def get_line(command, regexes): 128 | output = subprocess.check_output(command) 129 | lines = output.split('\n') 130 | try: 131 | return list(filter(lambda line: any(re.search(regex, line, re.IGNORECASE) for regex in regexes), lines))[0] 132 | except Exception as e: 133 | print("Cannot find regexes in output", e) 134 | return 135 | 136 | class OnMainThread(): 137 | def initWithCallback_(self, callback): 138 | self.callback = callback 139 | return self 140 | 141 | @objc.namedselector("run_:") 142 | def run_(self, args=None): 143 | self.callback() 144 | 145 | def run(self): 146 | try: 147 | self.pyobjc_performSelectorOnMainThread_withObject_("run_:", None) 148 | except Exception as e: 149 | print(e) 150 | rumps.quit_application() 151 | 152 | 153 | OnMainThreadObjCName = "OnMainThread_%d" % time.time() 154 | OnMainThread = type(OnMainThreadObjCName, (Foundation.NSObject,), dict(OnMainThread.__dict__)) 155 | 156 | 157 | class Timer(threading.Thread): 158 | def __init__(self, interval, callback, main=True): 159 | super(Timer, self).__init__(name="Timer for %ds for %s" % (interval, callback)) 160 | if main: 161 | self.callback = OnMainThread.alloc().initWithCallback_(callback) 162 | else: 163 | self.callback = callback 164 | self.name = callback.__name__ 165 | self.interval = interval 166 | 167 | def run(self): 168 | while True: 169 | time.sleep(self.interval) 170 | try: 171 | if hasattr(self.callback, "run"): 172 | self.callback.run() 173 | else: 174 | self.callback() 175 | except psutil.NoSuchProcess: 176 | pass # this is normal 177 | except Exception as e: 178 | error.error("Error in Timer callback '%s': %s" % (self.name, e)) 179 | 180 | image_cache = {} 181 | rumps_nsimage_from_file = rumps.rumps._nsimage_from_file 182 | 183 | def _nsimage_from_file(path, dimensions=None, template=None): 184 | if path in image_cache: 185 | return image_cache[path] 186 | else: 187 | image = rumps_nsimage_from_file(path, None, None) 188 | image_cache[path] = image 189 | return image 190 | 191 | rumps.rumps._nsimage_from_file = _nsimage_from_file -------------------------------------------------------------------------------- /src/versions/v00001/process.py: -------------------------------------------------------------------------------- 1 | #pylint: disable=E1101 2 | #pylint: disable=E0611 3 | 4 | import AppKit 5 | import error 6 | import Foundation 7 | import log 8 | import os 9 | import psutil 10 | import re 11 | import rumps 12 | import time 13 | import utils 14 | 15 | total_times = {} 16 | cpu_cache = {} 17 | processes = {} 18 | password = "" 19 | root_allowed = True 20 | dialog_open = False 21 | cached_processes = [] 22 | BACKGROUND_PROCESS_COUNT = 5 23 | 24 | 25 | def on_battery(): 26 | return psutil.sensors_battery() and not psutil.sensors_battery().power_plugged 27 | 28 | def battery_percentage(): 29 | return psutil.sensors_battery().percent if psutil.sensors_battery() else 100 30 | 31 | def clear_process_cache(): 32 | cpu_cache.clear() 33 | processes.clear() 34 | 35 | def get_cpu_percent(): 36 | if -1 in cpu_cache: 37 | return cpu_cache[-1] 38 | cpu_cache[-1] = percent = psutil.cpu_percent() 39 | return percent 40 | 41 | def cpu(pid=-1): 42 | if pid == 0: 43 | return 0 44 | if pid in cpu_cache: 45 | return cpu_cache[pid] 46 | try: 47 | total_time = get_total_time(pid) 48 | now = time.time() 49 | if not pid in total_times: 50 | total_times[pid] = (now, total_time) 51 | last_when, last_time = total_times[pid] 52 | result = (total_time - last_time) / (now - last_when + 0.00001) 53 | total_times[pid] = (now, total_time) 54 | cpu_cache[pid] = result 55 | return result 56 | except psutil.AccessDenied as e: 57 | cmd = ps_output = "???" 58 | try: 59 | cmd = "ps -p %s -o %%cpu | grep -v CPU" % pid 60 | ps_output = os.popen(cmd).read() or "0" 61 | return float(ps_output) / 100 62 | except: 63 | error.error("Cannot parse '%s' => '%s' into a float in process.cpu" % (cmd, ps_output)) 64 | return 0 65 | except (psutil.NoSuchProcess, psutil.ZombieProcess) as e: 66 | return 0 67 | except Exception as e: 68 | log.log("Unhandled Error in process.cpu", e) 69 | return 0 70 | 71 | def get_process(pid): 72 | if not pid in processes: 73 | try: 74 | processes[pid] = psutil.Process(pid) 75 | except (psutil.NoSuchProcess, psutil.ZombieProcess): 76 | return None 77 | return processes[pid] 78 | 79 | system_locations = [ 80 | "/usr/libexec/", 81 | "/usr/sbin/", 82 | "/sbin/", 83 | "/System/Library/", 84 | ] 85 | 86 | def is_system_process(pid): 87 | if pid < 2: 88 | return True 89 | name = location(pid) 90 | for path in system_locations: 91 | if name.startswith(path): 92 | return True 93 | return False 94 | 95 | def get_name(pid): 96 | try: 97 | name = get_process(pid).name() 98 | if len(name) == 16: 99 | # psutil truncates names to 16 characters 100 | name = location(pid).split("/")[-1] 101 | return name 102 | except: 103 | return "" 104 | 105 | def parent_pid(pid): 106 | return get_process(pid).ppid() 107 | 108 | def get_total_time(pid): 109 | proc = get_process(pid) 110 | if not proc: 111 | return 0 112 | times = proc.cpu_times() if pid != -1 else psutil.cpu_times() 113 | return times.user + times.system + getattr(times, "children_user", 0) + getattr(times, "children_system", 0) 114 | 115 | def child_processes(pid, includeSelf=True): 116 | p = get_process(pid) 117 | if not p: return [] 118 | kids = p.children() 119 | for grandkid in kids: 120 | kids.extend(child_processes(grandkid.pid, False)) 121 | if includeSelf: 122 | kids.append(get_process(pid)) 123 | return sorted(set(kids), key=lambda p: cpu(p.pid)) 124 | 125 | def parents(pid, includeSelf=True): 126 | processes = [] 127 | p = get_process(pid if includeSelf else parent_pid(pid)) 128 | while p.pid: 129 | processes.append(p) 130 | p = get_process(parent_pid(p.pid)) 131 | return sorted(set(processes), key=lambda p: cpu(p.pid)) 132 | 133 | def family(pid): 134 | return sorted(set(child_processes(pid) + parents(pid)), key=lambda p: cpu(p.pid)) 135 | 136 | def family_cpu_usage(pid): 137 | return sum(map(cpu, [p.pid for p in family(pid)])) 138 | 139 | def getMyPid(): 140 | return os.getpid() 141 | 142 | def details(pid): 143 | p = get_process(pid) 144 | return "%s - %s - %s\n" % ( 145 | p.cwd(), 146 | p.connections(), 147 | p.open_files() 148 | ) 149 | 150 | def get_processes(): 151 | return cached_processes 152 | 153 | def cache_processes(): 154 | global cached_processes 155 | foreground_tasks = family(utils.get_current_app_pid()) 156 | my_pid = os.getpid() 157 | exclude_pids = set(p.pid for p in foreground_tasks) 158 | 159 | def create_process(pid): 160 | try: 161 | name = get_name(pid) 162 | if pid in exclude_pids or pid == my_pid or name == "last": 163 | return None 164 | return get_process(pid) 165 | except: 166 | return None 167 | 168 | processes = filter(None, (create_process(pid) for pid in psutil.pids())) 169 | cached_processes = [ 170 | foreground_tasks, 171 | list(reversed(sorted(processes, key=lambda p: -cpu(p.pid))[:BACKGROUND_PROCESS_COUNT])) 172 | ] 173 | 174 | def location(pid): 175 | p = get_process(pid) 176 | if hasattr(p, "location"): 177 | return p.location 178 | try: 179 | path = p.cmdline()[0] 180 | except psutil.AccessDenied: 181 | path = os.popen("ps %d" % pid).read() 182 | except (AttributeError, IndexError, psutil.NoSuchProcess, psutil.ZombieProcess): 183 | return "" 184 | try: 185 | path = re.sub(r".*[0-9] (/[^-]*)-*.*", r"\1", path.split('\n')[-2]).strip() 186 | except: 187 | pass 188 | p.location = path 189 | return path 190 | 191 | def terminate_pid(pid): 192 | if is_system_process(pid): 193 | rumps.alert( 194 | "HappyMac: Terminate Canceled", 195 | "Process %s (%s) is a critical process that should not be terminated." % (pid, get_name(pid)) 196 | ) 197 | return False 198 | title = "Are you sure you want to terminate process %s (%s)?" % (pid, get_name(pid)) 199 | message = ("Terminating this process could lead to data loss.\n\n" + 200 | "We suggest you suspend the process, not terminate it.") 201 | if rumps.alert(title, message, ok="Terminate, I know what I am doing", cancel="Cancel"): 202 | return execute_shell_command("terminate", pid, "kill -9 %s" % pid) 203 | else: 204 | log.log("User skipped termination of process %d (%s)" % (pid, get_name(pid))) 205 | 206 | def suspend_pid(pid): 207 | if is_system_process(pid): 208 | rumps.alert( 209 | "HappyMac: Suspend Canceled", 210 | "Process %s (%s) is a critical process that should not be suspended." % (pid, get_name(pid)) 211 | ) 212 | return False 213 | return execute_shell_command("suspend", pid, "kill -STOP %s" % pid) 214 | 215 | def resume_pid(pid): 216 | if is_system_process(pid): 217 | return False 218 | return execute_shell_command("resume", pid, "kill -CONT %s" % pid) 219 | 220 | def execute_shell_command(operation, pid, command): 221 | output = os.popen("%s 2>&1" % command).read() 222 | if "Operation not permitted" in output: 223 | description = "%s process %d (%s)" % (operation, pid, get_name(pid)) 224 | return execute_as_root(description, command) 225 | else: 226 | return True 227 | 228 | def set_allow_root(allow_root): 229 | global root_allowed 230 | root_allowed = allow_root 231 | 232 | def execute_as_root(description, command): 233 | if not root_allowed: 234 | return False 235 | global password, dialog_open 236 | if dialog_open: 237 | return 238 | if not password: 239 | if not AppKit.NSThread.isMainThread() or utils.is_menu_open(): 240 | # Cannot show a dialogue on a background thread or when the menu is open 241 | return 242 | window = rumps.Window( 243 | "Please enter your admin or root password:", 244 | "HappyMac: To %s, an admin or root password is needed." % description, 245 | cancel = "Cancel" 246 | ) 247 | window._textfield = AppKit.NSSecureTextField.alloc().initWithFrame_(Foundation.NSMakeRect(0, 0, 200, 25)) 248 | window._alert.setAccessoryView_(window._textfield) 249 | window._alert.window().setInitialFirstResponder_(window._textfield) 250 | try: 251 | dialog_open = True 252 | response = window.run() 253 | finally: 254 | dialog_open = False 255 | if response.clicked: 256 | password = response.text 257 | if password: 258 | os.popen('echo "%s" | sudo -S %s' % (password, command)).read() 259 | return True 260 | return False 261 | 262 | def resume_all(): 263 | for pid in processes.keys(): 264 | if not resume_pid(pid): 265 | set_allow_root(False) 266 | 267 | def update(): 268 | clear_process_cache() -------------------------------------------------------------------------------- /src/versions/v00001/main.py: -------------------------------------------------------------------------------- 1 | import error 2 | import functools 3 | import gc 4 | import install 5 | import license 6 | import log 7 | import os 8 | import preferences 9 | import process 10 | import rumps 11 | import suspender 12 | import sys 13 | import time 14 | import utils 15 | import version_manager 16 | import webbrowser 17 | import psutil 18 | import socket 19 | 20 | RESOURCE_PATH = getattr(sys, "_MEIPASS", os.path.abspath(".")) 21 | ICONS = [ 22 | os.path.join(RESOURCE_PATH, "icons/happy-transparent.png"), 23 | os.path.join(RESOURCE_PATH, "icons/unhappy-transparent.png"), 24 | os.path.join(RESOURCE_PATH, "icons/sweating.png"), 25 | os.path.join(RESOURCE_PATH, "icons/burn.png"), 26 | ] 27 | if not os.path.exists(ICONS[0]): 28 | ICONS[0] = os.path.join(RESOURCE_PATH, "icons/happy.png") 29 | if not os.path.exists(ICONS[1]): 30 | ICONS[1] = os.path.join(RESOURCE_PATH, "icons/frown.png") 31 | DARK_ICONS = [ 32 | os.path.join(RESOURCE_PATH, "icons/happy-white-transparent.png"), 33 | os.path.join(RESOURCE_PATH, "icons/unhappy-white-transparent.png"), 34 | os.path.join(RESOURCE_PATH, "icons/sweating.png"), 35 | os.path.join(RESOURCE_PATH, "icons/burn.png"), 36 | ] 37 | if not os.path.exists(DARK_ICONS[0]): 38 | print("Missing icon 0") 39 | DARK_ICONS[0] = os.path.join(RESOURCE_PATH, "icons/happy.png") 40 | if not os.path.exists(DARK_ICONS[0]): 41 | print("Still missing icon 0") 42 | if not os.path.exists(DARK_ICONS[1]): 43 | DARK_ICONS[1] = os.path.join(RESOURCE_PATH, "icons/frown.png") 44 | 45 | TITLE_QUIT = "Quit HappyMac" 46 | TITLE_LOADING = "HappyMac: Sampling..." 47 | TITLE_ABOUT = "About HappyMac - %s" 48 | TITLE_CURRENT_PROCESSES = "Current App Tasks:" 49 | TITLE_OTHER_PROCESSES = "Background Tasks:" 50 | TITLE_SUSPENDED_PROCESSES = "Suspended Background Tasks:" 51 | 52 | TITLE_TERMINATE = "Terminate" 53 | TITLE_RESUME = "Resume" 54 | TITLE_SUSPEND_ALWAYS = "Suspend Always" 55 | TITLE_SUSPEND_ON_BATTERY = "Suspend on Battery" 56 | TITLE_GOOGLE = "Google this..." 57 | TITLE_GOOGLE_SYSTEM = "Google this system process..." 58 | TITLE_ON_ETHERNET = "You are using Ethernet" 59 | TITLE_NOT_ON_ETHERNET = "You are NOT using Ethernet" 60 | 61 | LAUNCHD_PID = 1 62 | IDLE_PROCESS_PERCENT_CPU = 3 63 | MENU_HIGHLIGHT_REDRAW_DELAY = 5 64 | 65 | running_local = not getattr(sys, "_MEIPASS", False) 66 | 67 | class HappyMacStatusBarApp(rumps.App): 68 | def __init__(self, quit_callback=None): 69 | super(HappyMacStatusBarApp, self).__init__("", quit_button=None) 70 | self.quit_button = None 71 | self.quit_callback = quit_callback 72 | self.menu = [ ] 73 | self.loading() 74 | self.menu._menu.setDelegate_(self) 75 | self.start = time.time() 76 | self.need_menu = False 77 | self.last_menu_item = None 78 | self.last_highlight_change = time.time() 79 | utils.set_menu_open(False) 80 | utils.Timer(1.0, self.main_update).start() 81 | utils.Timer(10.0, process.cache_processes, False).start() 82 | process.cache_processes() 83 | log.log("Started HappyMac %s" % version_manager.last_version()) 84 | 85 | def terminate(self, menuItem, pid): 86 | try: 87 | process.terminate_pid(pid) 88 | except: 89 | error.error("Error in menu callback") 90 | finally: 91 | self.handle_action() 92 | 93 | def resume(self, menuItem, pid): 94 | try: 95 | suspender.resume_process(pid, manual=True) 96 | except: 97 | error.error("Error in menu callback") 98 | finally: 99 | self.handle_action() 100 | 101 | def suspend(self, menuItem, pid, battery=False): 102 | try: 103 | suspender.suspend_process(pid, manual=True, battery=battery) 104 | except: 105 | error.error("Error in menu callback") 106 | finally: 107 | self.handle_action() 108 | 109 | def google(self, menuItem, pid): 110 | try: 111 | webbrowser.open("https://google.com/search?q=Mac process '%s'" % process.get_name(pid)) 112 | except: 113 | error.error("Error in menu callback") 114 | finally: 115 | self.handle_action() 116 | 117 | def version(self): 118 | return version_manager.last_version() 119 | 120 | def menu_item_for_process(self, p, resumable=False, suspendable=False): 121 | if not p: 122 | return None 123 | name = process.get_name(p.pid) 124 | if not name: 125 | return None 126 | cpu = process.cpu(p.pid) 127 | percent = max(0 if resumable else 1, int(100 * cpu)) 128 | if p.pid != utils.get_current_app_pid() and not resumable and percent < IDLE_PROCESS_PERCENT_CPU: 129 | return None 130 | item = rumps.MenuItem("%s - %d%%" % (name, percent)) 131 | item.icon = self.get_icon(percent) 132 | item.percent = percent 133 | item.pid = p.pid 134 | item.add(rumps.MenuItem(TITLE_GOOGLE, callback=functools.partial(self.google, pid=p.pid))) 135 | if resumable: 136 | item.add(rumps.MenuItem(TITLE_RESUME, callback=functools.partial(self.resume, pid=p.pid))) 137 | elif suspendable: 138 | item.add(rumps.MenuItem(TITLE_SUSPEND_ALWAYS, callback=functools.partial(self.suspend, pid=p.pid))) 139 | item.add(rumps.MenuItem(TITLE_SUSPEND_ON_BATTERY, callback=functools.partial(self.suspend, pid=p.pid, battery=True))) 140 | item.add(rumps.MenuItem(TITLE_TERMINATE, callback=functools.partial(self.terminate, pid=p.pid))) 141 | return item 142 | 143 | def create_menu(self): 144 | self.icon = DARK_ICONS[0] if utils.dark_mode() else ICONS[0] 145 | foreground_tasks, background_tasks = process.get_processes() 146 | suspender.manage(foreground_tasks, background_tasks) 147 | suspended_tasks = suspender.get_suspended_tasks() 148 | foreground_menu_items = filter(None, map(self.menu_item_for_process, foreground_tasks)) 149 | background_menu_items = filter(None, map(functools.partial(self.menu_item_for_process, suspendable=True), background_tasks)) 150 | suspended_menu_items = filter(None, map(functools.partial(self.menu_item_for_process, resumable=True), suspended_tasks)) 151 | self.clean_menu() 152 | self.menu = ( 153 | [ 154 | rumps.MenuItem(TITLE_ABOUT % self.version(), callback=self.about), 155 | None, 156 | rumps.MenuItem(TITLE_CURRENT_PROCESSES), 157 | ] + foreground_menu_items + [ 158 | None, 159 | rumps.MenuItem(TITLE_OTHER_PROCESSES), 160 | ] + background_menu_items + [ 161 | None, 162 | rumps.MenuItem(TITLE_SUSPENDED_PROCESSES), 163 | ] + suspended_menu_items + [ 164 | None, 165 | rumps.MenuItem(TITLE_ON_ETHERNET if utils.on_ethernet() else TITLE_NOT_ON_ETHERNET), 166 | rumps.MenuItem(TITLE_QUIT, callback=self.quit), 167 | ]) 168 | 169 | 170 | def menuWillOpen_(self, menu): 171 | utils.set_menu_open(True) 172 | self.need_menu = True 173 | self.create_menu() 174 | 175 | def clean_menu(self): 176 | for key, menu_item in self.menu.items(): 177 | if hasattr(menu_item, "pid"): 178 | menu_item.clear() 179 | self.menu.clear() 180 | 181 | def loading(self): 182 | self.clean_menu() 183 | item = rumps.MenuItem(TITLE_LOADING) 184 | item.icon = os.path.join(RESOURCE_PATH, "icons/happymac.png") 185 | self.menu = [ item ] 186 | 187 | def menuDidClose_(self, menu): 188 | utils.set_menu_open(False) 189 | self.need_menu = False 190 | self.loading() 191 | 192 | def update_statusbar(self): 193 | icon = self.get_icon(process.get_cpu_percent()) 194 | if self.icon is not icon: 195 | self.icon = icon 196 | 197 | def main_update(self, force_update=False): 198 | try: 199 | menu_item = self.menu._menu.highlightedItem() 200 | if self.last_menu_item is not menu_item: 201 | self.last_highlight_change = time.time() 202 | self.last_menu_item = menu_item 203 | process.update() 204 | self.update_statusbar() 205 | if not force_update: 206 | myCPU = process.cpu(process.getMyPid()) 207 | if myCPU > 0.25: 208 | return 209 | utils.clear_windows_cache() 210 | if self.need_menu: 211 | if time.time() - self.last_highlight_change > MENU_HIGHLIGHT_REDRAW_DELAY: 212 | self.create_menu() 213 | except Exception as e: 214 | error.error("Could not update menu: %s" % e) 215 | 216 | def menu_is_highlighted(self): 217 | return self.menu._menu.highlightedItem() 218 | 219 | def quit(self, menuItem=None): 220 | try: 221 | log.log("Quit - Ran for %d seconds" % int(time.time() - self.start)) 222 | suspender.exit() 223 | if self.quit_callback: 224 | self.quit_callback() 225 | except: 226 | error.error("Could not quit") 227 | finally: 228 | rumps.quit_application() 229 | 230 | def get_icon(self, percent): 231 | icons = DARK_ICONS if utils.dark_mode() else ICONS 232 | iconIndex = 0 if not percent else max(0, min(len(icons) - 1, int(percent * len(icons) / 70.0))) 233 | return icons[iconIndex] 234 | 235 | def about(self, menuItem=None): 236 | webbrowser.open("http://happymac.app") 237 | 238 | def handle_action(self, menuItem=None): 239 | return 240 | if menuItem: 241 | log.log("Handled menu item %s" % menuItem) 242 | self.main_update(force_update=True) 243 | 244 | 245 | def run(quit_callback=None): 246 | if license.get_license(): 247 | rumps.notification("HappyMac", "HappyMac is now running", "See the emoji icon in the status bar", sound=False) 248 | HappyMacStatusBarApp(quit_callback).run() 249 | -------------------------------------------------------------------------------- /src/tests/test_versions_v00001_process.py: -------------------------------------------------------------------------------- 1 | # 2 | # TODO: Fix tests, needs work on Auger's automatic test generator 3 | # 4 | import AppKit 5 | import Foundation 6 | import Quartz 7 | from Quartz import CG 8 | from Quartz import CoreGraphics 9 | import StringIO 10 | import abc 11 | from abc import ABCMeta 12 | import collections 13 | from collections import OrderedDict 14 | from collections import defaultdict 15 | import datetime 16 | import error 17 | import functools 18 | import gc 19 | import glob 20 | import imp 21 | import inspect 22 | import install 23 | import json 24 | import license 25 | import log 26 | from mock import patch 27 | import objc 28 | import objc._convenience 29 | import objc._convenience_mapping 30 | from objc._convenience_mapping import selector 31 | import objc._lazyimport 32 | from objc._lazyimport import ObjCLazyModule 33 | import os 34 | import os.path 35 | import preferences 36 | import process 37 | import psutil 38 | from psutil import AccessDenied 39 | from psutil import Popen 40 | import psutil._common 41 | from psutil._common import addr 42 | import re 43 | from re import Scanner 44 | import requests 45 | import rumps 46 | import rumps.rumps 47 | from rumps.rumps import App 48 | import struct 49 | import suspender 50 | import sys 51 | import tempfile 52 | import threading 53 | import time 54 | import traceback 55 | import unittest 56 | import utils 57 | import version_manager 58 | import versions 59 | import versions.v00001.main 60 | from versions.v00001.main import HappyMacStatusBarApp 61 | import versions.v00001.process 62 | import versions.v00001.suspender 63 | from versions.v00001.suspender import defaultdict 64 | import versions.v00001.utils 65 | from versions.v00001.utils import OnMainThread 66 | from versions.v00001.utils import Timer 67 | import webbrowser 68 | from webbrowser import BackgroundBrowser 69 | 70 | 71 | class ProcessTest(unittest.TestCase): 72 | 73 | @patch.object(Popen, 'pid') 74 | @patch.object(Popen, 'wrapper') 75 | @patch.object(Popen, '__hash__') 76 | @patch.object(Popen, '__eq__') 77 | def test_child_processes(self, mock___eq__, mock___hash__, mock_wrapper, mock_pid): 78 | mock___eq__.return_value = True 79 | mock___hash__.return_value = 3750713895001177006 80 | mock_wrapper.return_value = [psutil.Process(pid=33514, name='Python', started='15:52:08')] 81 | mock_pid.return_value = 508 82 | self.assertEqual( 83 | versions.v00001.process.child_processes(includeSelf=False,pid=30420), 84 | [] 85 | ) 86 | 87 | 88 | def test_clear_process_cache(self): 89 | self.assertEqual( 90 | versions.v00001.process.clear_process_cache(), 91 | None 92 | ) 93 | 94 | 95 | @patch.object(log, 'log') 96 | def test_cpu(self, mock_log): 97 | mock_log.return_value = None 98 | self.assertEqual( 99 | versions.v00001.process.cpu(pid=197), 100 | 0.0 101 | ) 102 | 103 | 104 | def test_create_process(self): 105 | self.assertIsInstance( 106 | versions.v00001.process.create_process(pid=403), 107 | psutil.Process 108 | ) 109 | 110 | 111 | @patch.object(App, 'clicked') 112 | @patch.object(App, '__init__') 113 | @patch.object(App, 'run') 114 | @patch.object(ObjCLazyModule, '__getattr__') 115 | @patch.object(App, 'text') 116 | @patch.object(objc._convenience, 'add_convenience_methods') 117 | def test_execute_as_root(self, mock_add_convenience_methods, mock_text, mock___getattr__, mock_run, mock___init__, mock_clicked): 118 | mock_add_convenience_methods.return_value = None 119 | mock_text.return_value = u'igov&1nm' 120 | mock___getattr__.return_value = NSWorkspace() 121 | mock_run.return_value = Response() 122 | mock___init__.return_value = None 123 | mock_clicked.return_value = 1 124 | self.assertEqual( 125 | versions.v00001.process.execute_as_root(command='kill -CONT 25779',description='resume process 25779 (CrashPlanService)'), 126 | True 127 | ) 128 | 129 | 130 | def test_execute_shell_command(self): 131 | self.assertEqual( 132 | versions.v00001.process.execute_shell_command(command='kill -STOP 396',operation='suspend',pid=396), 133 | True 134 | ) 135 | 136 | 137 | @patch.object(Popen, '__hash__') 138 | def test_family(self, mock___hash__): 139 | mock___hash__.return_value = 3750713895001177006 140 | self.assertEqual( 141 | versions.v00001.process.family(pid=395L), 142 | [psutil.Process(pid=32838, name='vsls-agent', started='15:44:35'), psutil.Process(pid=33509, name='Python', started='15:52:08'), psutil.Process(pid=33514, name='Python', started='15:52:08'), psutil.Process(pid=32263, name='bash', started='15:44:26'), psutil.Process(pid=32301, name='Code Helper', started='15:44:26'), psutil.Process(pid=467, name='Code Helper', started='2018-11-20 00:02:35'), psutil.Process(pid=32584, name='Code Helper', started='15:44:30'), psutil.Process(pid=32262, name='Code Helper', started='15:44:26'), psutil.Process(pid=1, name='launchd', started='2018-11-20 00:01:42'), psutil.Process(pid=436, name='Code Helper', started='2018-11-20 00:02:27'), psutil.Process(pid=395L, name='Electron', started='2018-11-20 00:02:24'), psutil.Process(pid=32261, name='Code Helper', started='15:44:24'), psutil.Process(pid=44191, name='Python', started='15:59:46')] 143 | ) 144 | 145 | 146 | @patch.object(psutil, 'cpu_percent') 147 | def test_get_cpu_percent(self, mock_cpu_percent): 148 | mock_cpu_percent.return_value = 31.4 149 | self.assertEqual( 150 | versions.v00001.process.get_cpu_percent(), 151 | 31.4 152 | ) 153 | 154 | 155 | @patch.object(Popen, 'name') 156 | def test_get_name(self, mock_name): 157 | mock_name.return_value = 'iTunesHelper' 158 | self.assertEqual( 159 | versions.v00001.process.get_name(pid=337), 160 | 'trustd' 161 | ) 162 | 163 | 164 | @patch.object(Popen, '__init__') 165 | def test_get_process(self, mock___init__): 166 | mock___init__.return_value = None 167 | self.assertIsInstance( 168 | versions.v00001.process.get_process(pid=581), 169 | psutil.Process 170 | ) 171 | 172 | 173 | @patch.object(addr, 'wrapper') 174 | def test_get_total_time(self, mock_wrapper): 175 | mock_wrapper.return_value = pcputimes() 176 | self.assertEqual( 177 | versions.v00001.process.get_total_time(pid=112), 178 | None 179 | ) 180 | 181 | 182 | def test_is_system_process(self): 183 | self.assertEqual( 184 | versions.v00001.process.is_system_process(pid=572), 185 | False 186 | ) 187 | 188 | 189 | @patch.object(Popen, 'cmdline') 190 | @patch.object(re, 'sub') 191 | def test_location(self, mock_sub, mock_cmdline): 192 | mock_sub.return_value = '/usr/libexec/xartstorageremoted' 193 | mock_cmdline.return_value = ['/Applications/Google Chrome.app/Contents/Versions/70.0.3538.102/Google Chrome Helper.app/Contents/MacOS/Google Chrome Helper', '--type=renderer', '--field-trial-handle=1014873197873945333,4216514382308589012,131072', '--service-pipe-token=6365535800482859512', '--lang=en-GB', '--extension-process', '--enable-offline-auto-reload', '--enable-offline-auto-reload-visible-only', '--num-raster-threads=2', '--enable-zero-copy', '--enable-gpu-memory-buffer-compositor-resources', '--enable-main-frame-before-activation', '--service-request-channel-token=6365535800482859512', '--renderer-client-id=8', '--no-v8-untrusted-code-mitigations', '--seatbelt-client=276'] 194 | self.assertEqual( 195 | versions.v00001.process.location(pid=1479), 196 | 'com.docker.osxfs' 197 | ) 198 | 199 | 200 | @patch.object(addr, 'wrapper') 201 | def test_parent_pid(self, mock_wrapper): 202 | mock_wrapper.return_value = pcputimes() 203 | self.assertEqual( 204 | versions.v00001.process.parent_pid(pid=1), 205 | 0 206 | ) 207 | 208 | 209 | @patch.object(Popen, 'pid') 210 | @patch.object(Popen, '__hash__') 211 | def test_parents(self, mock___hash__, mock_pid): 212 | mock___hash__.return_value = 3750713895001177006 213 | mock_pid.return_value = 508 214 | self.assertEqual( 215 | versions.v00001.process.parents(includeSelf=True,pid=395L), 216 | [psutil.Process(pid=1, name='launchd', started='2018-11-20 00:01:42'), psutil.Process(pid=395L, name='Electron', started='2018-11-20 00:02:24')] 217 | ) 218 | 219 | 220 | def test_resume_pid(self): 221 | self.assertEqual( 222 | versions.v00001.process.resume_pid(pid=25801), 223 | True 224 | ) 225 | 226 | 227 | def test_set_allow_root(self): 228 | self.assertEqual( 229 | versions.v00001.process.set_allow_root(allow_root=True), 230 | None 231 | ) 232 | 233 | 234 | def test_suspend_pid(self): 235 | self.assertEqual( 236 | versions.v00001.process.suspend_pid(pid=64674), 237 | True 238 | ) 239 | 240 | 241 | @patch.object(App, 'alert') 242 | def test_terminate_pid(self, mock_alert): 243 | mock_alert.return_value = 1 244 | self.assertEqual( 245 | versions.v00001.process.terminate_pid(pid=64674), 246 | True 247 | ) 248 | 249 | 250 | @patch.object(psutil, 'pids') 251 | def test_top(self, mock_pids): 252 | mock_pids.return_value = [44192, 44191, 44148, 35300, 35299, 33514, 33509, 33482, 33481, 32888, 32862, 32858, 32838, 32584, 32301, 32263, 32262, 32261, 32194, 31766, 31694, 31481, 30445, 30420, 29038, 29035, 29030, 29027, 27001, 26917, 26820, 25824, 25801, 25779, 22968, 22967, 44134, 5298, 8105, 7499, 13250, 3582, 14756, 88448, 86853, 86646, 57949, 80704, 80373, 77797, 98000, 97999, 97998, 97413, 64230, 40062, 38310, 66584, 64678, 64677, 64674, 15794, 6656, 98448, 64724, 11790, 68073, 699, 34942, 95521, 65607, 7005, 6997, 6996, 17991, 13470, 79714, 78533, 78515, 76230, 75782, 75775, 40800, 36160, 33373, 81677, 17265, 17202, 72990, 71797, 66105, 66099, 65530, 65390, 64931, 64930, 64929, 64927, 61982, 60891, 58202, 47988, 47987, 47985, 46725, 46724, 46190, 46156, 44008, 2474, 96208, 57070, 46979, 83674, 55689, 52949, 52273, 52270, 52192, 41106, 41105, 41104, 38896, 36134, 36133, 34475, 34474, 34316, 34223, 34202, 34043, 33925, 33923, 19519, 10238, 7286, 6219, 6218, 6217, 5696, 5584, 5182, 5179, 4143, 3408, 2539, 1489, 1481, 1480, 1479, 1478, 1477, 789, 748, 747, 743, 739, 738, 643, 622, 621, 619, 618, 615, 613, 612, 611, 610, 609, 606, 594, 593, 587, 584, 582, 581, 580, 579, 572, 570, 569, 567, 566, 562, 558, 545, 531, 530, 529, 527, 521, 519, 517, 511, 508, 507, 505, 504, 503, 502, 500, 499, 498, 496, 495, 494, 492, 490, 488, 486, 484, 479, 478, 476, 472, 467, 466, 465, 461, 460, 459, 458, 454, 451, 447, 444, 443, 437, 436, 434, 433, 432, 431, 429, 428, 427, 426, 423, 422, 421, 420, 419, 418, 417, 416, 415, 414, 413, 412, 411, 410, 409, 406, 405, 404, 403, 402, 401, 399, 397, 396, 395, 394, 393, 392, 390, 389, 388, 386, 385, 384, 381, 379, 378, 377, 376, 374, 371, 370, 369, 368, 366, 365, 361, 359, 358, 357, 356, 355, 353, 352, 351, 350, 349, 348, 347, 346, 345, 343, 342, 341, 340, 339, 338, 337, 336, 334, 333, 332, 330, 329, 328, 319, 318, 317, 312, 311, 305, 300, 299, 293, 289, 287, 286, 283, 282, 279, 278, 277, 270, 269, 268, 265, 264, 257, 254, 250, 249, 243, 241, 239, 238, 237, 236, 235, 234, 232, 229, 228, 225, 219, 214, 204, 199, 198, 197, 196, 195, 194, 189, 188, 187, 186, 185, 184, 181, 168, 166, 164, 161, 159, 154, 151, 150, 148, 147, 144, 137, 135, 129, 128, 126, 123, 122, 121, 120, 119, 118, 116, 115, 113, 112, 111, 110, 105, 104, 103, 101, 99, 98, 96, 95, 94, 93, 92, 91, 90, 87, 86, 85, 83, 78, 77, 76, 69, 68, 64, 63, 61, 59, 58, 57, 54, 52, 51, 50, 46, 45, 1, 0] 253 | self.assertEqual( 254 | versions.v00001.process.top(count=5,exclude=[psutil.Process(pid=32263, name='bash', started='15:44:26'), psutil.Process(pid=1, name='launchd', started='2018-11-20 00:01:42'), psutil.Process(pid=32261, name='Code Helper', started='15:44:24'), psutil.Process(pid=395, name='Electron', started='2018-11-20 00:02:24'), psutil.Process(pid=44191L, name='Python', started='15:59:46')]), 255 | [psutil.Process(pid=611, name='Google Chrome Helper', started='2018-11-20 00:02:51'), psutil.Process(pid=168, name='WindowServer', started='2018-11-20 00:01:51'), psutil.Process(pid=374, name='Google Chrome', started='2018-11-20 00:02:22'), psutil.Process(pid=1489, name='com.docker.hyperkit', started='2018-11-20 00:03:14'), psutil.Process(pid=235, name='mds_stores', started='2018-11-20 00:01:53')] 256 | ) 257 | 258 | 259 | if __name__ == "__main__": 260 | unittest.main() 261 | -------------------------------------------------------------------------------- /src/tests/test_versions_v00001_main.py: -------------------------------------------------------------------------------- 1 | # 2 | # TODO: Fix tests, needs work on Auger's automatic test generator 3 | # 4 | import abc 5 | from abc import ABCMeta 6 | import collections 7 | from collections import OrderedDict 8 | from collections import defaultdict 9 | import datetime 10 | import error 11 | import functools 12 | import gc 13 | import install 14 | import json 15 | import license 16 | import log 17 | from mock import patch 18 | import os 19 | import os.path 20 | import preferences 21 | import process 22 | import psutil 23 | from psutil import Popen 24 | import requests 25 | import rumps 26 | import rumps.rumps 27 | from rumps.rumps import App 28 | import suspender 29 | import sys 30 | import time 31 | import unittest 32 | import utils 33 | import version_manager 34 | import versions.v00001.main 35 | from versions.v00001.main import HappyMacStatusBarApp 36 | import versions.v00001.process 37 | import versions.v00001.suspender 38 | from versions.v00001.suspender import defaultdict 39 | import versions.v00001.utils 40 | from versions.v00001.utils import OnMainThread 41 | import webbrowser 42 | from webbrowser import BackgroundBrowser 43 | 44 | 45 | class MainTest(unittest.TestCase): 46 | @patch.object(App, '__new__') 47 | @patch.object(App, 'icon') 48 | @patch.object(App, 'menu') 49 | @patch.object(ABCMeta, '__instancecheck__') 50 | @patch.object(App, 'menu') 51 | @patch.object(App, '__init__') 52 | def test_create_menu(self, mock___init__, mock_menu, mock___instancecheck__, mock_menu, mock_icon, mock___new__): 53 | mock___init__.return_value = None 54 | mock_menu.return_value = Menu() 55 | mock___instancecheck__.return_value = False 56 | mock_menu.return_value = None 57 | mock_icon.return_value = None 58 | mock___new__.return_value = MenuItem() 59 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 60 | self.assertEqual( 61 | happymacstatusbarapp_instance.create_menu(), 62 | None 63 | ) 64 | 65 | 66 | def test_get_icon(self): 67 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 68 | self.assertEqual( 69 | happymacstatusbarapp_instance.get_icon(percent=31.4), 70 | '/Users/chris/dev/happymac/icons/unhappy-transparent.png' 71 | ) 72 | 73 | 74 | @patch.object(versions.v00001.process, 'get_name') 75 | @patch.object(log, 'log') 76 | @patch.object(webbrowser, 'open') 77 | def test_google(self, mock_open, mock_log, mock_get_name): 78 | mock_open.return_value = True 79 | mock_log.return_value = None 80 | mock_get_name.return_value = 'Python' 81 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 82 | self.assertEqual( 83 | happymacstatusbarapp_instance.google(menuItem= []; callback: ]>,pid=44784), 84 | None 85 | ) 86 | 87 | 88 | def test_handle_action(self): 89 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 90 | self.assertEqual( 91 | happymacstatusbarapp_instance.handle_action(menuItem=None), 92 | None 93 | ) 94 | 95 | 96 | @patch.object(versions.v00001.process, 'set_allow_root') 97 | def test_menuDidClose_(self, mock_set_allow_root): 98 | mock_set_allow_root.return_value = None 99 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 100 | self.assertEqual( 101 | happymacstatusbarapp_instance.menuDidClose_(menu= 102 | Title: 103 | Open bounds: [t=1418, l=2067, b=1099, r=2341] 104 | Supermenu: 0x0 (None), autoenable: YES 105 | Items: ( 106 | "", 107 | "", 108 | "", 109 | "", 110 | "", 111 | "", 112 | "", 113 | "", 114 | "", 115 | "", 116 | "", 117 | "", 118 | "", 119 | "", 120 | "", 121 | "" 122 | )), 123 | None 124 | ) 125 | 126 | 127 | @patch.object(versions.v00001.process, 'set_allow_root') 128 | def test_menuWillOpen_(self, mock_set_allow_root): 129 | mock_set_allow_root.return_value = None 130 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 131 | self.assertEqual( 132 | happymacstatusbarapp_instance.menuWillOpen_(menu= 133 | Title: 134 | Open bounds: [t=1418, l=2067, b=1099, r=2341] 135 | Supermenu: 0x0 (None), autoenable: YES 136 | Items: ( 137 | "", 138 | "", 139 | "", 140 | "", 141 | "", 142 | "", 143 | "", 144 | "", 145 | "", 146 | "", 147 | "", 148 | "", 149 | "", 150 | "", 151 | "", 152 | "" 153 | )), 154 | None 155 | ) 156 | 157 | 158 | @patch.object(App, 'menu') 159 | def test_menu_is_highlighted(self, mock_menu): 160 | mock_menu.return_value = Menu() 161 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 162 | self.assertEqual( 163 | happymacstatusbarapp_instance.menu_is_highlighted(), 164 | None 165 | ) 166 | 167 | 168 | @patch.object(App, '__new__') 169 | @patch.object(Popen, 'pid') 170 | @patch.object(versions.v00001.utils, 'get_current_app_pid') 171 | @patch.object(versions.v00001.process, 'cpu') 172 | @patch.object(versions.v00001.process, 'get_name') 173 | @patch.object(App, 'icon') 174 | @patch.object(App, '__init__') 175 | @patch.object(App, 'add') 176 | def test_menu_item_for_process(self, mock_add, mock___init__, mock_icon, mock_get_name, mock_cpu, mock_get_current_app_pid, mock_pid, mock___new__): 177 | mock_add.return_value = None 178 | mock___init__.return_value = None 179 | mock_icon.return_value = None 180 | mock_get_name.return_value = 'Python' 181 | mock_cpu.return_value = 0.43375777413908767 182 | mock_get_current_app_pid.return_value = __NSCFNumber() 183 | mock_pid.return_value = 508 184 | mock___new__.return_value = MenuItem() 185 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 186 | self.assertEqual( 187 | happymacstatusbarapp_instance.menu_item_for_process(p=psutil.Process(pid=610, name='Google Chrome Helper', started='2018-11-20 00:02:51'),resumable=False,suspendable=False), 188 | None 189 | ) 190 | 191 | 192 | @patch.object(versions.v00001.suspender, 'resume_process') 193 | def test_resume(self, mock_resume_process): 194 | mock_resume_process.return_value = None 195 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 196 | self.assertEqual( 197 | happymacstatusbarapp_instance.resume(menuItem= []; callback: ]>,pid=64674), 198 | None 199 | ) 200 | 201 | 202 | @patch.object(versions.v00001.suspender, 'suspend_process') 203 | def test_suspend(self, mock_suspend_process): 204 | mock_suspend_process.return_value = None 205 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 206 | self.assertEqual( 207 | happymacstatusbarapp_instance.suspend(menuItem= []; callback: ]>,pid=64674), 208 | None 209 | ) 210 | 211 | 212 | @patch.object(versions.v00001.process, 'terminate_pid') 213 | def test_terminate(self, mock_terminate_pid): 214 | mock_terminate_pid.return_value = True 215 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 216 | self.assertEqual( 217 | happymacstatusbarapp_instance.terminate(menuItem= []; callback: ]>,pid=64674), 218 | None 219 | ) 220 | 221 | 222 | @patch.object(versions.v00001.process, 'clear_process_cache') 223 | @patch.object(versions.v00001.suspender, 'activate_current_app') 224 | @patch.object(versions.v00001.utils, 'get_current_app_pid') 225 | @patch.object(versions.v00001.process, 'top') 226 | @patch.object(versions.v00001.utils, 'clear_windows_cache') 227 | @patch.object(versions.v00001.process, 'get_cpu_percent') 228 | @patch.object(versions.v00001.process, 'family') 229 | @patch.object(versions.v00001.suspender, 'get_suspended_tasks') 230 | @patch.object(versions.v00001.suspender, 'manage') 231 | def test_update(self, mock_manage, mock_get_suspended_tasks, mock_family, mock_get_cpu_percent, mock_clear_windows_cache, mock_top, mock_get_current_app_pid, mock_activate_current_app, mock_clear_process_cache): 232 | mock_manage.return_value = None 233 | mock_get_suspended_tasks.return_value = [psutil.Process(pid=87, name='CbOsxSensorServi', started='2018-11-20 00:01:50'), psutil.Process(pid=25779, name='CrashPlanService', started='14:08:58')] 234 | mock_family.return_value = [psutil.Process(pid=1, name='launchd', started='2018-11-20 00:01:42'), psutil.Process(pid=44148, status='terminated'), psutil.Process(pid=30445, name='Google Chrome Helper', started='15:26:37'), psutil.Process(pid=32194, name='Google Chrome Helper', started='15:42:39'), psutil.Process(pid=27001, name='Google Chrome Helper', started='14:41:21'), psutil.Process(pid=33481, name='Google Chrome Helper', started='15:51:15'), psutil.Process(pid=80373, name='Google Chrome Helper', started='21:11:46'), psutil.Process(pid=98000, name='Google Chrome Helper', started='20:58:54'), psutil.Process(pid=97413, name='Google Chrome Helper', started='20:58:52'), psutil.Process(pid=615, name='Google Chrome Helper', started='2018-11-20 00:02:51'), psutil.Process(pid=572, name='Google Chrome Helper', started='2018-11-20 00:02:46'), psutil.Process(pid=738, name='Google Chrome Helper', started='2018-11-20 00:02:58'), psutil.Process(pid=4143, name='Google Chrome Helper', started='2018-11-20 00:03:40'), psutil.Process(pid=40800, name='Google Chrome Helper', started='2018-11-20 11:30:16'), psutil.Process(pid=32888, name='Google Chrome Helper', started='15:46:00'), psutil.Process(pid=582, name='Google Chrome Helper', started='2018-11-20 00:02:47'), psutil.Process(pid=618, name='Google Chrome Helper', started='2018-11-20 00:02:51'), psutil.Process(pid=6656, name='Google Chrome Helper', started='18:20:40'), psutil.Process(pid=97998, name='Google Chrome Helper', started='20:58:54'), psutil.Process(pid=64230, name='Google Chrome Helper', started='20:52:05'), psutil.Process(pid=40062, name='Google Chrome Helper', started='19:53:25'), psutil.Process(pid=97999, name='Google Chrome Helper', started='20:58:54'), psutil.Process(pid=567, name='Google Chrome Helper', started='2018-11-20 00:02:46'), psutil.Process(pid=98448, name='Google Chrome Helper', started='18:19:14'), psutil.Process(pid=521, name='Google Chrome Helper', started='2018-11-20 00:02:41'), psutil.Process(pid=562, name='Google Chrome Helper', started='2018-11-20 00:02:46'), psutil.Process(pid=622, name='Google Chrome Helper', started='2018-11-20 00:02:51'), psutil.Process(pid=612, name='Google Chrome Helper', started='2018-11-20 00:02:51'), psutil.Process(pid=570, name='Google Chrome Helper', started='2018-11-20 00:02:46'), psutil.Process(pid=566, name='Google Chrome Helper', started='2018-11-20 00:02:46'), psutil.Process(pid=619, name='Google Chrome Helper', started='2018-11-20 00:02:51'), psutil.Process(pid=613, name='Google Chrome Helper', started='2018-11-20 00:02:51'), psutil.Process(pid=569, name='Google Chrome Helper', started='2018-11-20 00:02:46'), psutil.Process(pid=38310, name='Google Chrome Helper', started='19:52:51'), psutil.Process(pid=80704, name='Google Chrome Helper', started='21:11:49'), psutil.Process(pid=30420, name='Google Chrome Helper', started='15:24:52'), psutil.Process(pid=610, name='Google Chrome Helper', started='2018-11-20 00:02:51'), psutil.Process(pid=584, name='Google Chrome Helper', started='2018-11-20 00:02:47'), psutil.Process(pid=29027, name='Google Chrome Helper', started='14:58:59'), psutil.Process(pid=580, name='Google Chrome Helper', started='2018-11-20 00:02:46'), psutil.Process(pid=68073, name='Google Chrome Helper', started='2018-11-21 13:48:34'), psutil.Process(pid=33482, name='Google Chrome Helper', started='15:51:17'), psutil.Process(pid=579, name='Google Chrome Helper', started='2018-11-20 00:02:46'), psutil.Process(pid=77797, name='Google Chrome Helper', started='21:11:29'), psutil.Process(pid=472, name='Google Chrome Helper', started='2018-11-20 00:02:35'), psutil.Process(pid=699, name='Google Chrome Helper', started='2018-11-21 13:35:41'), psutil.Process(pid=621, name='Google Chrome Helper', started='2018-11-20 00:02:51'), psutil.Process(pid=587, name='Google Chrome Helper', started='2018-11-20 00:02:47'), psutil.Process(pid=609, name='Google Chrome Helper', started='2018-11-20 00:02:51'), psutil.Process(pid=611, name='Google Chrome Helper', started='2018-11-20 00:02:51'), psutil.Process(pid=64724, name='Google Chrome Helper', started='18:12:13'), psutil.Process(pid=581, name='Google Chrome Helper', started='2018-11-20 00:02:47'), psutil.Process(pid=458, name='Google Chrome Helper', started='2018-11-20 00:02:33'), psutil.Process(pid=374L, name='Google Chrome', started='2018-11-20 00:02:22')] 235 | mock_get_cpu_percent.return_value = 31.4 236 | mock_clear_windows_cache.return_value = None 237 | mock_top.return_value = [psutil.Process(pid=64724, name='Google Chrome Helper', started='18:12:13'), psutil.Process(pid=374, name='Google Chrome', started='2018-11-20 00:02:22'), psutil.Process(pid=611, name='Google Chrome Helper', started='2018-11-20 00:02:51'), psutil.Process(pid=1489, name='com.docker.hyperkit', started='2018-11-20 00:03:14'), psutil.Process(pid=610, name='Google Chrome Helper', started='2018-11-20 00:02:51')] 238 | mock_get_current_app_pid.return_value = __NSCFNumber() 239 | mock_activate_current_app.return_value = None 240 | mock_clear_process_cache.return_value = None 241 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 242 | self.assertEqual( 243 | happymacstatusbarapp_instance.update(force_update=False), 244 | None 245 | ) 246 | 247 | 248 | @patch.object(App, 'menu') 249 | @patch.object(App, '__delitem__') 250 | @patch.object(OrderedDict, 'items') 251 | @patch.object(App, 'insert_after') 252 | def test_update_menu(self, mock_insert_after, mock_items, mock___delitem__, mock_menu): 253 | mock_insert_after.return_value = None 254 | mock_items.return_value = [(u'About HappyMac - v00001', []; callback: >]>), ('separator_1', ), (u'Current App Tasks', []; callback: None]>), (u'Google Chrome - 22%', ['Google this...', 'Terminate']; callback: None]>), (u'Google Chrome Helper - 14%', ['Google this...', 'Terminate']; callback: None]>), ('separator_2', ), (u'Background Tasks:', []; callback: None]>), (u'WindowServer - 5%', ['Google this...', 'Suspend', 'Terminate']; callback: None]>), (u'Code Helper - 7%', ['Google this...', 'Suspend', 'Terminate']; callback: None]>), (u'Code Helper - 6%', ['Google this...', 'Suspend', 'Terminate']; callback: None]>), (u'Electron - 4%', ['Google this...', 'Suspend', 'Terminate']; callback: None]>), ('separator_3', ), (u'Suspended Background Tasks:', []; callback: None]>), (u'com.docker.hyperkit - 0%', ['Google this...', 'Resume', 'Terminate']; callback: None]>), (u'qemu-system-i386 - 0%', ['Google this...', 'Resume', 'Terminate']; callback: None]>), (u'CbOsxSensorService - 0%', ['Google this...', 'Resume', 'Terminate']; callback: None]>), (u'idea - 0%', ['Google this...', 'Resume', 'Terminate']; callback: None]>), (u'CrashPlanService - 0%', ['Google this...', 'Resume', 'Terminate']; callback: None]>), (u'CrashPlanWeb - 0%', ['Google this...', 'Resume', 'Terminate']; callback: None]>), ('separator_4', ), (u'Quit HappyMac', []; callback: >]>)] 255 | mock___delitem__.return_value = None 256 | mock_menu.return_value = Menu() 257 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 258 | self.assertEqual( 259 | happymacstatusbarapp_instance.update_menu(background_tasks=[psutil.Process(pid=159, name='coreaudiod', started='2018-11-20 00:01:50'), psutil.Process(pid=168, name='WindowServer', started='2018-11-20 00:01:51'), psutil.Process(pid=1489, name='com.docker.hyperkit', started='2018-11-20 00:03:14'), psutil.Process(pid=64674, status='terminated'), psutil.Process(pid=610, name='Google Chrome Helper', started='2018-11-20 00:02:51')],foreground_tasks=[psutil.Process(pid=32838, name='vsls-agent', started='15:44:35'), psutil.Process(pid=33509, name='Python', started='15:52:08'), psutil.Process(pid=33514, name='Python', started='15:52:08'), psutil.Process(pid=32263, name='bash', started='15:44:26'), psutil.Process(pid=32584, name='Code Helper', started='15:44:30'), psutil.Process(pid=32301, name='Code Helper', started='15:44:26'), psutil.Process(pid=467, name='Code Helper', started='2018-11-20 00:02:35'), psutil.Process(pid=32262, name='Code Helper', started='15:44:26'), psutil.Process(pid=1, name='launchd', started='2018-11-20 00:01:42'), psutil.Process(pid=436, name='Code Helper', started='2018-11-20 00:02:27'), psutil.Process(pid=395L, name='Electron', started='2018-11-20 00:02:24'), psutil.Process(pid=32261, name='Code Helper', started='15:44:24'), psutil.Process(pid=44191, name='Python', started='15:59:46')],force_update=True,suspended_tasks=[psutil.Process(pid=25801, name='CrashPlanWeb', started='14:09:04'), psutil.Process(pid=25779, name='CrashPlanService', started='14:08:58'), psutil.Process(pid=396, name='idea', started='2018-11-20 00:02:24'), psutil.Process(pid=87, name='CbOsxSensorServi', started='2018-11-20 00:01:50')]), 260 | None 261 | ) 262 | 263 | 264 | @patch.object(versions.v00001.process, 'get_cpu_percent') 265 | @patch.object(App, 'icon') 266 | def test_update_statusbar(self, mock_icon, mock_get_cpu_percent): 267 | mock_icon.return_value = None 268 | mock_get_cpu_percent.return_value = 31.4 269 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 270 | self.assertEqual( 271 | happymacstatusbarapp_instance.update_statusbar(), 272 | None 273 | ) 274 | 275 | 276 | @patch.object(version_manager, 'last_version') 277 | def test_version(self, mock_last_version): 278 | mock_last_version.return_value = 'v00001' 279 | happymacstatusbarapp_instance = HappyMacStatusBarApp() 280 | self.assertEqual( 281 | happymacstatusbarapp_instance.version(), 282 | 'v00001' 283 | ) 284 | 285 | 286 | if __name__ == "__main__": 287 | unittest.main() 288 | --------------------------------------------------------------------------------