├── 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 |
--------------------------------------------------------------------------------