├── .gitignore ├── payload └── Library │ ├── nudge │ ├── Logs │ │ └── .gitignore │ └── Resources │ │ ├── update_ss.png │ │ ├── company_logo.png │ │ ├── nudge.nib │ │ ├── keyedobjects.nib │ │ └── designable.nib │ │ ├── nibbler.py │ │ ├── gurl.py │ │ └── nudge │ └── LaunchAgents │ └── com.erikng.nudge.plist ├── images └── nudge_ss.png ├── .github └── ISSUE_TEMPLATE │ └── nudge-python-is-end-of-life--eol-.md ├── Bom.txt ├── example_config.json ├── scripts └── postinstall ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.DS_Store 3 | *.pkg 4 | config.json -------------------------------------------------------------------------------- /payload/Library/nudge/Logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /images/nudge_ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macadmins/nudge-python/HEAD/images/nudge_ss.png -------------------------------------------------------------------------------- /payload/Library/nudge/Resources/update_ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macadmins/nudge-python/HEAD/payload/Library/nudge/Resources/update_ss.png -------------------------------------------------------------------------------- /payload/Library/nudge/Resources/company_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macadmins/nudge-python/HEAD/payload/Library/nudge/Resources/company_logo.png -------------------------------------------------------------------------------- /payload/Library/nudge/Resources/nudge.nib/keyedobjects.nib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macadmins/nudge-python/HEAD/payload/Library/nudge/Resources/nudge.nib/keyedobjects.nib -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/nudge-python-is-end-of-life--eol-.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Nudge-Python is End of Life (EOL) 3 | about: If this is a critical issue, please file it, otherwise your issue will be immediately 4 | closed. 5 | title: '' 6 | labels: '' 7 | assignees: '' 8 | 9 | --- 10 | 11 | **A clear and concise description of what the bug is.** 12 | -------------------------------------------------------------------------------- /payload/Library/LaunchAgents/com.erikng.nudge.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | com.erikng.nudge 7 | LimitLoadToSessionType 8 | 9 | Aqua 10 | 11 | ProgramArguments 12 | 13 | /Library/nudge/Resources/nudge 14 | --jsonurl=https://fake.domain.com/path/to/config.json 15 | 16 | RunAtLoad 17 | 18 | StandardOutPath 19 | /Library/nudge/Logs/nudge.log 20 | StandardErrorPath 21 | /Library/nudge/Logs/nudge.log 22 | StartCalendarInterval 23 | 24 | 25 | Minute 26 | 0 27 | 28 | 29 | Minute 30 | 30 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /Bom.txt: -------------------------------------------------------------------------------- 1 | . 40755 0/0 2 | ./Library 40755 0/0 3 | ./Library/LaunchAgents 40755 0/0 4 | ./Library/LaunchAgents/._com.erikng.nudge.plist 100755 0/0 0 0 5 | ./Library/LaunchAgents/com.erikng.nudge.plist 100755 0/0 857 1072280589 6 | ./Library/nudge 40755 0/0 7 | ./Library/nudge/._Logs 40755 0/0 0 0 8 | ./Library/nudge/Logs 40755 0/0 9 | ./Library/nudge/Logs/.gitignore 100755 0/0 14 838015408 10 | ./Library/nudge/Resources 40755 0/0 11 | ./Library/nudge/Resources/._company_logo.png 100755 0/0 0 0 12 | ./Library/nudge/Resources/._gurl.py 100644 0/0 0 0 13 | ./Library/nudge/Resources/._nibbler.py 100755 0/0 0 0 14 | ./Library/nudge/Resources/._nudge 100755 0/0 0 0 15 | ./Library/nudge/Resources/._nudge.nib 40755 0/0 0 0 16 | ./Library/nudge/Resources/._update_ss.png 100644 0/0 0 0 17 | ./Library/nudge/Resources/company_logo.png 100755 0/0 16622 2197199182 18 | ./Library/nudge/Resources/gurl.py 100644 0/0 30055 934536547 19 | ./Library/nudge/Resources/nibbler.py 100755 0/0 4552 2490287542 20 | ./Library/nudge/Resources/nudge 100755 0/0 27534 4292505195 21 | ./Library/nudge/Resources/nudge.nib 40755 0/0 22 | ./Library/nudge/Resources/nudge.nib/designable.nib 100644 0/0 22346 3673242246 23 | ./Library/nudge/Resources/nudge.nib/keyedobjects.nib 100644 0/0 17328 2903061278 24 | ./Library/nudge/Resources/update_ss.png 100644 0/0 404022 3240320862 25 | -------------------------------------------------------------------------------- /example_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "preferences": { 3 | "button_title_text": "Ready to start the update?", 4 | "button_sub_titletext": "Click on the button below.", 5 | "cut_off_date": "2019-12-31-00:00", 6 | "cut_off_date_warning": 14, 7 | "days_between_notifications": 0, 8 | "logo_path": "/path/to/company_logo.png", 9 | "main_subtitle_text": "A friendly reminder from your local IT team", 10 | "main_title_text": "macOS Update", 11 | "minimum_os_sub_build_version": "18G84", 12 | "minimum_os_version": "10.14.6", 13 | "more_info_url": "https://google.com", 14 | "no_timer": false, 15 | "paragraph1_text": "A fully up-to-date device is required to ensure that IT can your accurately protect your computer.", 16 | "paragraph2_text": "If you do not update your computer, you may lose access to some items necessary for your day-to-day tasks.", 17 | "paragraph3_text": "To begin the update, simply click on the button below and follow the provided steps.", 18 | "paragraph_title_text": "A security update is required on your machine.", 19 | "path_to_app": "/Applications/Install macOS Mojave.app", 20 | "random_delay": false, 21 | "screenshot_path": "/path/to/update_ss.png", 22 | "timer_day_1": 600, 23 | "timer_day_3": 7200, 24 | "timer_elapsed": 10, 25 | "timer_final": 60, 26 | "timer_initial": 14400, 27 | "update_minor": false, 28 | "update_minor_days": 14 29 | }, 30 | "software_updates": [{ 31 | "name": "091-22861", 32 | "force_install_date": "2019-12-31-00:00" 33 | }] 34 | } 35 | -------------------------------------------------------------------------------- /scripts/postinstall: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | # 3 | # Copyright 2019-Present Erik Gomez. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the 'License'); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an 'AS IS' BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # If you change your agent file name, update the following line 18 | launch_agent_plist_name='com.erikng.nudge.plist' 19 | 20 | # Base paths 21 | launch_agent_base_path='Library/LaunchAgents/' 22 | 23 | # Load agent if installing to a running system 24 | if [[ $3 == "/" ]] ; then 25 | # Fail the install if the admin forgets to change their paths and they don't exist. 26 | if [ ! -e "$3/${launch_agent_base_path}${launch_agent_plist_name}" ]; then 27 | echo "LaunchAgent missing, exiting" 28 | exit 1 29 | fi 30 | 31 | # Make the Log path 777 to cheat - do this before loading LaunchAgent 32 | /bin/mkdir -p "$3/Library/nudge/Logs" 33 | /bin/chmod -R 777 "$3/Library/nudge/Logs" 34 | 35 | # Current console user information 36 | console_user=$(/usr/bin/stat -f "%Su" /dev/console) 37 | console_user_uid=$(/usr/bin/id -u "$console_user") 38 | 39 | # Only enable the LaunchAgent if there is a user logged in, otherwise rely on built in LaunchAgent behavior 40 | if [[ -z "$console_user" ]]; then 41 | echo "Did not detect user" 42 | elif [[ "$console_user" == "loginwindow" ]]; then 43 | echo "Detected Loginwindow Environment" 44 | elif [[ "$console_user" == "_mbsetupuser" ]]; then 45 | echo "Detect SetupAssistant Environment" 46 | else 47 | # This is a deprecated command, but until Apple kills it, it is going to be used 48 | /bin/launchctl asuser "${console_user_uid}" /bin/launchctl list | /usr/bin/grep 'nudge' 49 | # Unload the agent so it can be triggered on re-install 50 | if [[ $? -eq 0 ]]; then 51 | /bin/launchctl asuser "${console_user_uid}" /bin/launchctl unload "$3${launch_agent_base_path}${launch_agent_plist_name}" 52 | fi 53 | # Load the launch agent 54 | /bin/launchctl asuser "${console_user_uid}" /bin/launchctl load "$3${launch_agent_base_path}${launch_agent_plist_name}" 55 | fi 56 | fi 57 | -------------------------------------------------------------------------------- /payload/Library/nudge/Resources/nibbler.py: -------------------------------------------------------------------------------- 1 | # Use the "Identifier" property of your control in Interface Builder and give 2 | # your controls a name. Then use the 'attach' method on your Nibbler to link 3 | # the control to a python function 4 | 5 | from Foundation import NSObject, NSBundle 6 | from AppKit import NSNib, NSApp, NSApplication 7 | import objc 8 | import os 9 | import os.path 10 | import types 11 | 12 | from ctypes import CDLL, Structure, POINTER, c_uint32, byref 13 | from ctypes.util import find_library 14 | 15 | 16 | class ProcessSerialNumber(Structure): 17 | _fields_ = [('highLongOfPSN', c_uint32), ('lowLongOfPSN', c_uint32)] 18 | 19 | 20 | kCurrentProcess = 2 21 | kProcessTransformToForegroundApplication = 1 22 | kProcessTransformToUIElementAppication = 4 23 | ApplicationServices = CDLL(find_library('ApplicationServices')) 24 | TransformProcessType = ApplicationServices.TransformProcessType 25 | TransformProcessType.argtypes = [POINTER(ProcessSerialNumber), c_uint32] 26 | 27 | 28 | def views_recursive(view_obj): 29 | yield view_obj 30 | for x in view_obj.subviews(): 31 | for y in views_recursive(x): 32 | yield y 33 | 34 | 35 | def views_dict(nib_obj): 36 | # Find the NSWindow instance at the top level 37 | all_windows = [x for x in nib_obj if x.className() == 'NSWindow'] 38 | win = all_windows[0] 39 | # Now find all the views within the window where the identifier is defined 40 | top_view = win.contentView() 41 | v_dict = dict() 42 | for v in views_recursive(top_view): 43 | ident = v.identifier() 44 | if ident is not None: 45 | if not ident.startswith('_'): 46 | # Someone has customized it, remember it 47 | v_dict[ident] = v 48 | return v_dict 49 | 50 | 51 | def quit_app(): 52 | NSApplication.sharedApplication().terminate_(None) 53 | 54 | 55 | class genericController(NSObject): 56 | def setTheThing_(self, f_obj): 57 | self.f = f_obj 58 | 59 | def doTheThing_(self, sender): 60 | if hasattr(self, 'f'): 61 | self.f() 62 | 63 | 64 | def func_to_controller_selector(f_obj): 65 | o = genericController.alloc().init() 66 | o.setTheThing_(f_obj) 67 | return o 68 | 69 | 70 | class Nibbler(object): 71 | def __init__(self, path): 72 | bundle = NSBundle.mainBundle() 73 | info = bundle.localizedInfoDictionary() or bundle.infoDictionary() 74 | # Did you know you can override parts of infoDictionary (Info.plist, 75 | # after loading) even though Apple says it's read-only? 76 | info['LSUIElement'] = '1' 77 | # Initialize our shared application instance 78 | NSApplication.sharedApplication() 79 | # Two possibilities here 80 | # Either the path is a directory and we really want the file inside it 81 | # or the path is just a real .nib file 82 | if os.path.isdir(path): 83 | # Ok, so they just saved it from Xcode, not their fault 84 | # let's fix the path 85 | path = os.path.join(path, 'keyedobjects.nib') 86 | with open(path, 'rb') as f: 87 | # get nib bytes 88 | buffer = memoryview 89 | d = buffer(f.read()) 90 | n_obj = NSNib.alloc().initWithNibData_bundle_(d, None) 91 | placeholder_obj = NSObject.alloc().init() 92 | result, n = n_obj.instantiateWithOwner_topLevelObjects_( 93 | placeholder_obj, None) 94 | self.hidden = True 95 | self.nib_contents = n 96 | self.win = [ 97 | x for x in self.nib_contents if x.className() == 'NSWindow'][0] 98 | self.views = views_dict(self.nib_contents) 99 | self._attached = [] 100 | 101 | def attach(self, func, identifier_label): 102 | # look up the object with the identifer provided 103 | o = self.views[identifier_label] 104 | # get the classname of the object and handle appropriately 105 | o_class = o.className() 106 | if o_class == 'NSButton': 107 | # Wow, we actually know how to do this one 108 | temp = func_to_controller_selector(func) 109 | # hold onto it 110 | self._attached.append(temp) 111 | o.setTarget_(temp) 112 | # button.setAction_(objc.selector(controller.buttonClicked_, 113 | # signature='v@:')) 114 | o.setAction_(temp.doTheThing_) 115 | 116 | def run(self): 117 | if self.hidden: 118 | psn = ProcessSerialNumber(0, kCurrentProcess) 119 | ApplicationServices.TransformProcessType( 120 | psn, kProcessTransformToUIElementAppication) 121 | else: 122 | psn = ProcessSerialNumber(0, kCurrentProcess) 123 | ApplicationServices.TransformProcessType( 124 | psn, kProcessTransformToForegroundApplication) 125 | self.win.makeKeyAndOrderFront_(None) 126 | self.win.display() 127 | NSApp.activateIgnoringOtherApps_(True) 128 | NSApp.run() 129 | 130 | def quit(self): 131 | NSApplication.sharedApplication().terminate_(None) 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nudge-Python (macadmin's Slack #nudge) 2 | Nudge-Python is now considered End Of Life (EOL) as of February 11th, 2021. The last official version is 2.0.1 which enabled Big Sur support through the use of [macadmins/python 3.9.1](https://github.com/macadmins/python) 3 | 4 | If you are using a fork of Nudge-Python [(a well known one is located here)](https://github.com/LcTrKiD/nudge), support will not be offered unless the developer of the fork decides to continue support. 5 | 6 | [While Nudge-Python does support Big Sur, it is recommended to use the new version of Nudge, based on SwiftUI 5.2. You can find that project here.](https://github.com/macadmins/nudge) 7 | 8 | On March 1st, 2021, this project will be marked as archived through GitHub. 9 | 10 | ## Nudge functionality overview 11 | - Nudge, rather than trying to install updates, merely prompts users to install updates via an approved method (System Preferences, Munki, Jamf, etc.). 12 | - By default, Nudge will open every 30 minutes, at the 0 and 30 minute mark. This is because of the default launch agent. If you find this behavior too aggressive, please change the launch agent. 13 | - The timers are for if the user minimizes/hides the window. It will re-load the window into the foreground, taking precedence over any window. 14 | - Read Alan Siu's [Introduction to Nudge](https://www.alansiu.net/2019/12/24/nudge/) blog post for a more in-depth introduction to Nudge. 15 | 16 | ## Macadmins Python 17 | As of v2.0.1, Nudge now requires [macadmins/python 3.9.1](https://github.com/macadmins/python). 3.9.1 is backwards compatible with 10.9.5 through Big Sur 11.0 18 | 19 | Gurl has been updated from the Munki 4.0 release. 20 | 21 | Nibbler has been updated to support python 3. 22 | 23 | ## Important Information 24 | You most certainly want to customize the following values: 25 | 26 | - `cut_off_date` 27 | - `main_subtitle_text` 28 | - `more_info_url` 29 | - `minimum_os_version` 30 | - `minimum_os_sub_build_version` 31 | 32 | Also, you will at the very least want to change the `company_logo.png` 33 | 34 | ## Screenshots 35 | ![Screenshot Nudge](/images/nudge_ss.png?raw=true) 36 | 37 | ## Building this package 38 | You will need to use [munki-pkg](https://github.com/munki/munki-pkg) to build this package. 39 | 40 | ** This is not to be confused with [munki](https://github.com/munki/munki).** Munki-Pkg is a standalone project that works with all macOS tooling and MDMs 41 | 42 | ## Credits 43 | This tool would not be possible without [nibbler](https://github.com/pudquick/nibbler), written by [Michael Lynn](https://twitter.com/mikeymikey). 44 | 45 | ### Notes 46 | Because of the way git works, nudge will not contain the `Logs` folder required for the postinstall to complete. 47 | 48 | In order to create a properly working package, you will need to run the following command _before_ building the package: 49 | `munkipkg --sync /path/to/cloned_repo/nudge`. 50 | 51 | ## OS Support v1 52 | The following operating system and versions have been tested. 53 | - 10.10.0, 10.10.5 54 | - 10.11.0, 10.11.6 55 | - 10.12.0, 10.12.6 56 | - 10.13.0 10.13.3, 10.13.6 57 | - 10.14 -> 10.14.6 58 | - 10.15 -> 10.15.1 59 | 60 | ## OS Support v2 (macadmins python) 61 | The following operating system and versions have been tested with the embedded python. 62 | - 10.14 63 | - 10.15 64 | - 11 65 | 66 | ## Configuration File 67 | Essentially every component of the UI is customizable, all through a JSON configuration file. An [example file](/example_config.json) is available within the code repository. 68 | 69 | ### Defined config file 70 | To define a configuration file, use the `jsonurl` script parameter. 71 | ```bash 72 | --jsonurl=https://fake.domain.com/path/to/config.json 73 | ``` 74 | ```bash 75 | --jsonurl=file:///path/to/local/config.json 76 | ``` 77 | 78 | ### Default config file 79 | If you prefer to deploy the configuration file to each client, it needs to be placed in the `Resources` directory and named `nudge.json`. If this file exists, `jsonurl` does not need to be set. 80 | 81 | ## Preferences 82 | A description of each preference is listed below. 83 | 84 | ### Cutoff date 85 | Cut off date in UTC. 86 | ```json 87 | "cut_off_date": "2018-12-31-00:00" 88 | ``` 89 | 90 | ### Cut off date warning 91 | This is the number, in days, of when to start the initial UI warning. When this set of days passes, the user will be required to hit the **I Understand** button, followed by the **Close** button to exit out of the UI. 92 | ```json 93 | "cut_off_date_warning": 14 94 | ``` 95 | 96 | ### Logo path 97 | A custom logo path. Alternatively, just replace the included `company_logo.png`. 98 | ```json 99 | "logo_path": "/Some/Custom/Path/company_logo.png" 100 | ``` 101 | 102 | ### Button Title text 103 | This is the first set of text above the **Update Machine** button. 104 | 105 | ```json 106 | "button_title_text": "Ready to start the update?" 107 | ``` 108 | 109 | ### Button Sub-title text 110 | This is the second set of text above the **Update Machine** button. 111 | 112 | ```json 113 | "button_sub_titletext": "Click on the button below." 114 | ``` 115 | 116 | ### Dismissal Count Threshold 117 | This is the amount of times a user can disregard nudge before more aggressive behaviors kick in. 118 | 119 | ```json 120 | "dismissal_count_threshold": 100 121 | ``` 122 | 123 | ### URL for self-servicing upgrade app 124 | This is the full URL for a local self-servicing app such as Jamf Self 125 | Service or Munki Managed Software Center linking directly to a Jamf 126 | policy or Munki catalog item to install a major version upgrade. 127 | 128 | This is useful in situations where users do not have administrative 129 | privileges and cannot run `Install macOS...app` directly. This option 130 | has no effect on minor version _updates_ – only full version OS upgrades. 131 | 132 | Provide a full URL with the correct protocol for your self-servicing 133 | app. 134 | 135 | - Open Jamf Self Service to main page: `jamfselfservice://content` 136 | - Open Jamf Self Service to view a policy by ID number: `jamfselfservice://content?entity=policy&id=&action=view` 137 | - Open Jamf Self Service to execute a policy by ID number: `jamfselfservice://content?entity=policy&id=&action=execute` 138 | - Open Manage Software Center to the detail page for an item: `munki://detail-` 139 | 140 | ```json 141 | "local_url_for_upgrade": "jamfselfservice://content?entity=policy&id=&action=view" 142 | ``` 143 | 144 | Note: If `local_url_for_upgrade` is provided, `path_to_app` is **ignored** 145 | in the configuration file. 146 | 147 | ### Minimum OS Version 148 | This is the minimum OS version a machine must be on to not receive this UI. 149 | ```json 150 | "minimum_os_version": "10.13.6" 151 | ``` 152 | 153 | ### Minimum OS Sub Version 154 | 155 | This is the minimum OS version a machine must be on to not receive this UI. 156 | ```json 157 | "minimum_os_sub_build_version": "18G103" 158 | ``` 159 | 160 | ### More info URL 161 | This is the URL to open when the **More Info** button is clicked. 162 | ```json 163 | "more_info_url": "https://google.com" 164 | ``` 165 | 166 | ### Main Title text 167 | This is the main, bolded text at the very top. 168 | ```json 169 | "main_title_text": "macOS Update" 170 | ``` 171 | 172 | ### Main Sub-title text 173 | This is the text right under the main title. 174 | ```json 175 | "main_subtitle_text": "A friendly reminder from your local IT team" 176 | ``` 177 | 178 | ### Paragraph Title text 179 | This is the bolded portion of the UI towards the top. 180 | ```json 181 | "paragraph_title_text": "A security update is required on your machine." 182 | ``` 183 | 184 | ### Paragraph 1 text 185 | This is the text for the first paragraph. 186 | ```json 187 | "paragraph1_text": "A fully up-to-date device is required to ensure that IT can your accurately protect your computer." 188 | ``` 189 | 190 | ### Paragraph 2 text 191 | This is the text for the second paragraph. 192 | ```json 193 | "paragraph2_text": "If you do not update your computer, you may lose access to some items necessary for your day-to-day tasks." 194 | ``` 195 | 196 | ### Paragraph 3 text 197 | This is the text for the third paragraph. 198 | ```json 199 | "paragraph3_text": "To begin the update, simply click on the button below and follow the provided steps." 200 | ``` 201 | 202 | ### Path to app 203 | This is the path to the macOS installer application. 204 | ```json 205 | "path_to_app": "/Applications/Install macOS High Sierra.app" 206 | ``` 207 | 208 | Note: This setting is ignored when `local_url_for_upgrade` is provided. 209 | 210 | ### Days Between Notifications 211 | Instead of having the Nudge GUI appear every half hour, make sure there is at least this many days between notifications. 212 | *Note*: if you set this to something other than 0, it may not be evaluated in full 24-hour increments. For example, if the Nudge GUI appeared on Monday in the afternoon, it may appear Tuesday morning. 213 | ```json 214 | "days_between_notifications": 0 215 | ``` 216 | 217 | ### No timer 218 | Do not attempt to restore the nudge GUI to the front of a user's window. 219 | 220 | ```json 221 | "no_timer": false 222 | ``` 223 | 224 | ### Timer Initial 225 | The time, in seconds, to restore the nudge GUI to the front of a user's window. This will occur indefinitely until the UI is closed or macOS update is installed. 226 | 227 | This is when the update cutoff is over three days. 228 | ```json 229 | "timer_initial": 14400 230 | ``` 231 | 232 | ### Timer Day 3 233 | The time, in seconds, to restore the nudge GUI to the front of a user's window. This will occur indefinitely until the UI is closed or macOS update is installed. 234 | 235 | This is when the update cutoff is three days or less. 236 | ```json 237 | "timer_day_3": 7200 238 | ``` 239 | 240 | ### Timer Day 1 241 | The time, in seconds, to restore the nudge GUI to the front of a user's window. This will occur indefinitely until the UI is closed or macOS update is installed. 242 | 243 | This is when the update cutoff is one day or less. 244 | ```json 245 | "timer_day_1": 600 246 | ``` 247 | 248 | ### Timer Final 249 | The time, in seconds, to restore the nudge GUI to the front of a user's window. This will occur indefinitely until the UI is closed or macOS update is installed. 250 | 251 | This is when the update is one hour or less. 252 | ```json 253 | "timer_final": 60 254 | ``` 255 | 256 | ### Timer Elapsed 257 | The time, in seconds, to restore the nudge GUI to the front of a user's window. This will occur indefinitely until the UI is closed or macOS update is installed. 258 | 259 | This is when the update cutoff has elapsed. 260 | ```json 261 | "timer_elapsed": 10 262 | ``` 263 | 264 | ### Update screenshot path 265 | A custom update screenshot path. Alternatively, just replace the included `update_ss.png`. 266 | ```json 267 | "screenshot_path": "/Some/Custom/Path/update_ss.png" 268 | ``` 269 | 270 | ### Random Delay 271 | Randomize the UI popup by up to 20 minutes. 272 | ```json 273 | "random_delay": true 274 | ``` 275 | 276 | ### Update Minor 277 | Perform Apple Software Updates. 278 | ```json 279 | "update_minor": true 280 | ``` 281 | 282 | ### Update Minor Days 283 | Grace period before UI pops up to prompt for minor updates. 284 | ```json 285 | "update_minor_days": 14 286 | ``` 287 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /payload/Library/nudge/Resources/nudge.nib/designable.nib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 88 | 96 | 107 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | -------------------------------------------------------------------------------- /payload/Library/nudge/Resources/gurl.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright 2009-2019 Greg Neagle. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the 'License'); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an 'AS IS' BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """ 17 | gurl.py 18 | 19 | Created by Greg Neagle on 2013-11-21. 20 | Modified in Feb 2016 to add support for NSURLSession. 21 | Updated June 2019 for compatibility with Python 3 and PyObjC 5.1.2+ 22 | 23 | curl replacement using NSURLConnection and friends 24 | 25 | Tested with PyObjC 2.5.1 (inlcuded with macOS) 26 | and with PyObjC 5.2b1. Should also work with PyObjC 5.1.2. 27 | May fail with other versions of PyObjC due to issues with completion handler 28 | signatures. 29 | """ 30 | from __future__ import absolute_import, print_function 31 | 32 | import os 33 | import xattr 34 | 35 | try: 36 | # Python 2 37 | from urlparse import urlparse 38 | except ImportError: 39 | # Python 3 40 | from urllib.parse import urlparse 41 | 42 | 43 | # builtin super doesn't work with Cocoa classes in recent PyObjC releases. 44 | # pylint: disable=redefined-builtin,no-name-in-module 45 | from objc import super 46 | # pylint: enable=redefined-builtin,no-name-in-module 47 | 48 | # PyLint cannot properly find names inside Cocoa libraries, so issues bogus 49 | # No name 'Foo' in module 'Bar' warnings. Disable them. 50 | # pylint: disable=E0611 51 | 52 | 53 | from Foundation import (NSBundle, NSRunLoop, NSData, NSDate, 54 | NSObject, NSURL, NSURLConnection, 55 | NSMutableURLRequest, 56 | NSURLRequestReloadIgnoringLocalCacheData, 57 | NSURLResponseUnknownLength, 58 | NSLog, 59 | NSURLCredential, NSURLCredentialPersistenceNone, 60 | NSPropertyListSerialization, 61 | NSPropertyListMutableContainersAndLeaves, 62 | NSPropertyListXMLFormat_v1_0) 63 | 64 | try: 65 | from Foundation import NSURLSession, NSURLSessionConfiguration 66 | from CFNetwork import (kCFNetworkProxiesHTTPSEnable, 67 | kCFNetworkProxiesHTTPEnable) 68 | NSURLSESSION_AVAILABLE = True 69 | except ImportError: 70 | NSURLSESSION_AVAILABLE = False 71 | 72 | # Disable PyLint complaining about 'invalid' names 73 | # pylint: disable=C0103 74 | 75 | if NSURLSESSION_AVAILABLE: 76 | # NSURLSessionAuthChallengeDisposition enum constants 77 | NSURLSessionAuthChallengeUseCredential = 0 78 | NSURLSessionAuthChallengePerformDefaultHandling = 1 79 | NSURLSessionAuthChallengeCancelAuthenticationChallenge = 2 80 | NSURLSessionAuthChallengeRejectProtectionSpace = 3 81 | 82 | # NSURLSessionResponseDisposition enum constants 83 | NSURLSessionResponseCancel = 0 84 | NSURLSessionResponseAllow = 1 85 | NSURLSessionResponseBecomeDownload = 2 86 | 87 | # TLS/SSLProtocol enum constants 88 | kSSLProtocolUnknown = 0 89 | kSSLProtocol3 = 2 90 | kTLSProtocol1 = 4 91 | kTLSProtocol11 = 7 92 | kTLSProtocol12 = 8 93 | kDTLSProtocol1 = 9 94 | 95 | # define a helper function for block callbacks 96 | import ctypes 97 | import objc 98 | CALLBACK_HELPER_AVAILABLE = True 99 | try: 100 | _objc_so = ctypes.cdll.LoadLibrary( 101 | os.path.join(objc.__path__[0], '_objc.so')) 102 | except OSError: 103 | # could not load _objc.so 104 | CALLBACK_HELPER_AVAILABLE = False 105 | else: 106 | PyObjCMethodSignature_WithMetaData = ( 107 | _objc_so.PyObjCMethodSignature_WithMetaData) 108 | PyObjCMethodSignature_WithMetaData.restype = ctypes.py_object 109 | 110 | def objc_method_signature(signature_str): 111 | '''Return a PyObjCMethodSignature given a call signature in string 112 | format''' 113 | return PyObjCMethodSignature_WithMetaData( 114 | ctypes.create_string_buffer(signature_str), None, False) 115 | 116 | # pylint: enable=E0611 117 | 118 | # disturbing hack warning! 119 | # this works around an issue with App Transport Security on 10.11 120 | bundle = NSBundle.mainBundle() 121 | info = bundle.localizedInfoDictionary() or bundle.infoDictionary() 122 | info['NSAppTransportSecurity'] = {'NSAllowsArbitraryLoads': True} 123 | 124 | 125 | def NSLogWrapper(message): 126 | '''A wrapper function for NSLog to prevent format string errors''' 127 | NSLog('%@', message) 128 | 129 | 130 | ssl_error_codes = { 131 | -9800: u'SSL protocol error', 132 | -9801: u'Cipher Suite negotiation failure', 133 | -9802: u'Fatal alert', 134 | -9803: u'I/O would block (not fatal)', 135 | -9804: u'Attempt to restore an unknown session', 136 | -9805: u'Connection closed gracefully', 137 | -9806: u'Connection closed via error', 138 | -9807: u'Invalid certificate chain', 139 | -9808: u'Bad certificate format', 140 | -9809: u'Underlying cryptographic error', 141 | -9810: u'Internal error', 142 | -9811: u'Module attach failure', 143 | -9812: u'Valid cert chain, untrusted root', 144 | -9813: u'Cert chain not verified by root', 145 | -9814: u'Chain had an expired cert', 146 | -9815: u'Chain had a cert not yet valid', 147 | -9816: u'Server closed session with no notification', 148 | -9817: u'Insufficient buffer provided', 149 | -9818: u'Bad SSLCipherSuite', 150 | -9819: u'Unexpected message received', 151 | -9820: u'Bad MAC', 152 | -9821: u'Decryption failed', 153 | -9822: u'Record overflow', 154 | -9823: u'Decompression failure', 155 | -9824: u'Handshake failure', 156 | -9825: u'Misc. bad certificate', 157 | -9826: u'Bad unsupported cert format', 158 | -9827: u'Certificate revoked', 159 | -9828: u'Certificate expired', 160 | -9829: u'Unknown certificate', 161 | -9830: u'Illegal parameter', 162 | -9831: u'Unknown Cert Authority', 163 | -9832: u'Access denied', 164 | -9833: u'Decoding error', 165 | -9834: u'Decryption error', 166 | -9835: u'Export restriction', 167 | -9836: u'Bad protocol version', 168 | -9837: u'Insufficient security', 169 | -9838: u'Internal error', 170 | -9839: u'User canceled', 171 | -9840: u'No renegotiation allowed', 172 | -9841: u'Peer cert is valid, or was ignored if verification disabled', 173 | -9842: u'Server has requested a client cert', 174 | -9843: u'Peer host name mismatch', 175 | -9844: u'Peer dropped connection before responding', 176 | -9845: u'Decryption failure', 177 | -9846: u'Bad MAC', 178 | -9847: u'Record overflow', 179 | -9848: u'Configuration error', 180 | -9849: u'Unexpected (skipped) record in DTLS'} 181 | 182 | 183 | class Gurl(NSObject): 184 | '''A class for getting content from a URL 185 | using NSURLConnection/NSURLSession and friends''' 186 | 187 | # since we inherit from NSObject, PyLint issues a few bogus warnings 188 | # pylint: disable=W0232,E1002 189 | 190 | # Don't want to define the attributes twice that are initialized in 191 | # initWithOptions_(), so: 192 | # pylint: disable=E1101,W0201 193 | 194 | GURL_XATTR = 'com.googlecode.munki.downloadData' 195 | 196 | def initWithOptions_(self, options): 197 | '''Set up our Gurl object''' 198 | self = super(Gurl, self).init() 199 | if not self: 200 | return None 201 | 202 | self.follow_redirects = options.get('follow_redirects', False) 203 | self.ignore_system_proxy = options.get('ignore_system_proxy', False) 204 | self.destination_path = options.get('file') 205 | self.can_resume = options.get('can_resume', False) 206 | self.url = options.get('url') 207 | self.additional_headers = options.get('additional_headers', {}) 208 | self.username = options.get('username') 209 | self.password = options.get('password') 210 | self.download_only_if_changed = options.get( 211 | 'download_only_if_changed', False) 212 | self.cache_data = options.get('cache_data') 213 | self.connection_timeout = options.get('connection_timeout', 60) 214 | if NSURLSESSION_AVAILABLE: 215 | self.minimum_tls_protocol = options.get( 216 | 'minimum_tls_protocol', kTLSProtocol1) 217 | 218 | self.log = options.get('logging_function', NSLogWrapper) 219 | 220 | self.resume = False 221 | self.response = None 222 | self.headers = None 223 | self.status = None 224 | self.error = None 225 | self.SSLerror = None 226 | self.done = False 227 | self.redirection = [] 228 | self.destination = None 229 | self.bytesReceived = 0 230 | self.expectedLength = -1 231 | self.percentComplete = 0 232 | self.connection = None 233 | self.session = None 234 | self.task = None 235 | return self 236 | 237 | def start(self): 238 | '''Start the connection''' 239 | if not self.destination_path: 240 | self.log('No output file specified.') 241 | self.done = True 242 | return 243 | url = NSURL.URLWithString_(self.url) 244 | request = ( 245 | NSMutableURLRequest.requestWithURL_cachePolicy_timeoutInterval_( 246 | url, NSURLRequestReloadIgnoringLocalCacheData, 247 | self.connection_timeout)) 248 | if self.additional_headers: 249 | for header, value in self.additional_headers.items(): 250 | request.setValue_forHTTPHeaderField_(value, header) 251 | # does the file already exist? See if we can resume a partial download 252 | if os.path.isfile(self.destination_path): 253 | stored_data = self.getStoredHeaders() 254 | if (self.can_resume and 'expected-length' in stored_data and 255 | ('last-modified' in stored_data or 'etag' in stored_data)): 256 | # we have a partial file and we're allowed to resume 257 | self.resume = True 258 | local_filesize = os.path.getsize(self.destination_path) 259 | byte_range = 'bytes=%s-' % local_filesize 260 | request.setValue_forHTTPHeaderField_(byte_range, 'Range') 261 | if self.download_only_if_changed and not self.resume: 262 | stored_data = self.cache_data or self.getStoredHeaders() 263 | if 'last-modified' in stored_data: 264 | request.setValue_forHTTPHeaderField_( 265 | stored_data['last-modified'], 'if-modified-since') 266 | if 'etag' in stored_data: 267 | request.setValue_forHTTPHeaderField_( 268 | stored_data['etag'], 'if-none-match') 269 | if NSURLSESSION_AVAILABLE: 270 | configuration = ( 271 | NSURLSessionConfiguration.defaultSessionConfiguration()) 272 | 273 | # optional: ignore system http/https proxies (10.9+ only) 274 | if self.ignore_system_proxy is True: 275 | configuration.setConnectionProxyDictionary_( 276 | {kCFNetworkProxiesHTTPEnable: False, 277 | kCFNetworkProxiesHTTPSEnable: False}) 278 | 279 | # set minimum supported TLS protocol (defaults to TLS1) 280 | configuration.setTLSMinimumSupportedProtocol_( 281 | self.minimum_tls_protocol) 282 | 283 | self.session = ( 284 | NSURLSession.sessionWithConfiguration_delegate_delegateQueue_( 285 | configuration, self, None)) 286 | self.task = self.session.dataTaskWithRequest_(request) 287 | self.task.resume() 288 | else: 289 | self.connection = NSURLConnection.alloc().initWithRequest_delegate_( 290 | request, self) 291 | 292 | def cancel(self): 293 | '''Cancel the connection''' 294 | if self.connection: 295 | if NSURLSESSION_AVAILABLE: 296 | self.session.invalidateAndCancel() 297 | else: 298 | self.connection.cancel() 299 | self.done = True 300 | 301 | def isDone(self): 302 | '''Check if the connection request is complete. As a side effect, 303 | allow the delegates to work by letting the run loop run for a bit''' 304 | if self.done: 305 | return self.done 306 | # let the delegates do their thing 307 | NSRunLoop.currentRunLoop().runUntilDate_( 308 | NSDate.dateWithTimeIntervalSinceNow_(.1)) 309 | return self.done 310 | 311 | def getStoredHeaders(self): 312 | '''Returns any stored headers for self.destination_path''' 313 | # try to read stored headers 314 | try: 315 | stored_plist_bytestr = xattr.getxattr( 316 | self.destination_path, self.GURL_XATTR) 317 | except (KeyError, IOError): 318 | return {} 319 | data = NSData.dataWithBytes_length_( 320 | stored_plist_bytestr, len(stored_plist_bytestr)) 321 | dataObject, _plistFormat, error = ( 322 | NSPropertyListSerialization. 323 | propertyListFromData_mutabilityOption_format_errorDescription_( 324 | data, NSPropertyListMutableContainersAndLeaves, None, None)) 325 | if error: 326 | return {} 327 | return dataObject 328 | 329 | def storeHeaders_(self, headers): 330 | '''Store dictionary data as an xattr for self.destination_path''' 331 | plistData, error = ( 332 | NSPropertyListSerialization. 333 | dataFromPropertyList_format_errorDescription_( 334 | headers, NSPropertyListXMLFormat_v1_0, None)) 335 | if error: 336 | byte_string = b'' 337 | else: 338 | try: 339 | byte_string = bytes(plistData) 340 | except NameError: 341 | byte_string = str(plistData) 342 | try: 343 | xattr.setxattr(self.destination_path, self.GURL_XATTR, byte_string) 344 | except IOError as err: 345 | self.log('Could not store metadata to %s: %s' 346 | % (self.destination_path, err)) 347 | 348 | def normalizeHeaderDict_(self, a_dict): 349 | '''Since HTTP header names are not case-sensitive, we normalize a 350 | dictionary of HTTP headers by converting all the key names to 351 | lower case''' 352 | 353 | # yes, we don't use 'self'! 354 | # pylint: disable=R0201 355 | 356 | new_dict = {} 357 | for key, value in a_dict.items(): 358 | new_dict[key.lower()] = value 359 | return new_dict 360 | 361 | def recordError_(self, error): 362 | '''Record any error info from completed connection/session''' 363 | self.error = error 364 | # If this was an SSL error, try to extract the SSL error code. 365 | if 'NSUnderlyingError' in error.userInfo(): 366 | ssl_code = error.userInfo()['NSUnderlyingError'].userInfo().get( 367 | '_kCFNetworkCFStreamSSLErrorOriginalValue', None) 368 | if ssl_code: 369 | self.SSLerror = (ssl_code, ssl_error_codes.get( 370 | ssl_code, 'Unknown SSL error')) 371 | 372 | def removeExpectedSizeFromStoredHeaders(self): 373 | '''If a successful transfer, clear the expected size so we 374 | don\'t attempt to resume the download next time''' 375 | if str(self.status).startswith('2'): 376 | # remove the expected-size from the stored headers 377 | headers = self.getStoredHeaders() 378 | if 'expected-length' in headers: 379 | del headers['expected-length'] 380 | self.storeHeaders_(headers) 381 | 382 | def URLSession_task_didCompleteWithError_(self, _session, _task, error): 383 | '''NSURLSessionTaskDelegate method.''' 384 | if self.destination and self.destination_path: 385 | self.destination.close() 386 | self.removeExpectedSizeFromStoredHeaders() 387 | if error: 388 | self.recordError_(error) 389 | self.done = True 390 | 391 | def connection_didFailWithError_(self, _connection, error): 392 | '''NSURLConnectionDelegate method 393 | Sent when a connection fails to load its request successfully.''' 394 | self.recordError_(error) 395 | self.done = True 396 | if self.destination and self.destination_path: 397 | self.destination.close() 398 | 399 | def connectionDidFinishLoading_(self, _connection): 400 | '''NSURLConnectionDataDelegate method 401 | Sent when a connection has finished loading successfully.''' 402 | self.done = True 403 | if self.destination and self.destination_path: 404 | self.destination.close() 405 | self.removeExpectedSizeFromStoredHeaders() 406 | 407 | def handleResponse_withCompletionHandler_( 408 | self, response, completionHandler): 409 | '''Handle the response to the connection''' 410 | self.response = response 411 | self.bytesReceived = 0 412 | self.percentComplete = -1 413 | self.expectedLength = response.expectedContentLength() 414 | 415 | download_data = {} 416 | if response.className() == u'NSHTTPURLResponse': 417 | # Headers and status code only available for HTTP/S transfers 418 | self.status = response.statusCode() 419 | self.headers = dict(response.allHeaderFields()) 420 | normalized_headers = self.normalizeHeaderDict_(self.headers) 421 | if 'last-modified' in normalized_headers: 422 | download_data['last-modified'] = normalized_headers[ 423 | 'last-modified'] 424 | if 'etag' in normalized_headers: 425 | download_data['etag'] = normalized_headers['etag'] 426 | download_data['expected-length'] = self.expectedLength 427 | 428 | # self.destination is defined in initWithOptions_ 429 | # pylint: disable=E0203 430 | 431 | if not self.destination and self.destination_path: 432 | if self.status == 206 and self.resume: 433 | # 206 is Partial Content response 434 | stored_data = self.getStoredHeaders() 435 | if (not stored_data or 436 | stored_data.get('etag') != download_data.get('etag') or 437 | stored_data.get('last-modified') != download_data.get( 438 | 'last-modified')): 439 | # file on server is different than the one 440 | # we have a partial for 441 | self.log( 442 | 'Can\'t resume download; file on server has changed.') 443 | if completionHandler: 444 | # tell the session task to cancel 445 | completionHandler(NSURLSessionResponseCancel) 446 | else: 447 | # cancel the connection 448 | self.connection.cancel() 449 | self.log('Removing %s' % self.destination_path) 450 | os.unlink(self.destination_path) 451 | # restart and attempt to download the entire file 452 | self.log( 453 | 'Restarting download of %s' % self.destination_path) 454 | os.unlink(self.destination_path) 455 | self.start() 456 | return 457 | # try to resume 458 | self.log('Resuming download for %s' % self.destination_path) 459 | # add existing file size to bytesReceived so far 460 | local_filesize = os.path.getsize(self.destination_path) 461 | self.bytesReceived = local_filesize 462 | self.expectedLength += local_filesize 463 | # open file for append 464 | self.destination = open(self.destination_path, 'ab') 465 | 466 | elif str(self.status).startswith('2'): 467 | # not resuming, just open the file for writing 468 | self.destination = open(self.destination_path, 'wb') 469 | # store some headers with the file for use if we need to resume 470 | # the download and for future checking if the file on the server 471 | # has changed 472 | self.storeHeaders_(download_data) 473 | 474 | if completionHandler: 475 | # tell the session task to continue 476 | completionHandler(NSURLSessionResponseAllow) 477 | 478 | def URLSession_dataTask_didReceiveResponse_completionHandler_( 479 | self, _session, _task, response, completionHandler): 480 | '''NSURLSessionDataDelegate method''' 481 | if CALLBACK_HELPER_AVAILABLE: 482 | completionHandler.__block_signature__ = objc_method_signature(b'v@i') 483 | self.handleResponse_withCompletionHandler_(response, completionHandler) 484 | 485 | def connection_didReceiveResponse_(self, _connection, response): 486 | '''NSURLConnectionDataDelegate delegate method 487 | Sent when the connection has received sufficient data to construct the 488 | URL response for its request.''' 489 | self.handleResponse_withCompletionHandler_(response, None) 490 | 491 | def handleRedirect_newRequest_withCompletionHandler_( 492 | self, response, request, completionHandler): 493 | '''Handle the redirect request''' 494 | def allowRedirect(): 495 | '''Allow the redirect''' 496 | if completionHandler: 497 | completionHandler(request) 498 | return None 499 | return request 500 | 501 | def denyRedirect(): 502 | '''Deny the redirect''' 503 | if completionHandler: 504 | completionHandler(None) 505 | return None 506 | 507 | newURL = request.URL().absoluteString() 508 | if response is None: 509 | # the request has changed the NSURLRequest in order to standardize 510 | # its format, for example, changing a request for 511 | # http://www.apple.com to http://www.apple.com/. This occurs because 512 | # the standardized, or canonical, version of the request is used for 513 | # cache management. Pass the request back as-is 514 | # (it appears that at some point Apple also defined a redirect like 515 | # http://developer.apple.com to https://developer.apple.com to be 516 | # 'merely' a change in the canonical URL.) 517 | # Further -- it appears that this delegate method isn't called at 518 | # all in this scenario, unlike NSConnectionDelegate method 519 | # connection:willSendRequest:redirectResponse: 520 | # we'll leave this here anyway in case we're wrong about that 521 | self.log('Allowing redirect to: %s' % newURL) 522 | return allowRedirect() 523 | # If we get here, it appears to be a real redirect attempt 524 | # Annoyingly, we apparently can't get access to the headers from the 525 | # site that told us to redirect. All we know is that we were told 526 | # to redirect and where the new location is. 527 | self.redirection.append([newURL, dict(response.allHeaderFields())]) 528 | newParsedURL = urlparse(newURL) 529 | # This code was largely based on the work of Andreas Fuchs 530 | # (https://github.com/munki/munki/pull/465) 531 | if self.follow_redirects is True or self.follow_redirects == 'all': 532 | # Allow the redirect 533 | self.log('Allowing redirect to: %s' % newURL) 534 | return allowRedirect() 535 | elif (self.follow_redirects == 'https' 536 | and newParsedURL.scheme == 'https'): 537 | # Once again, allow the redirect 538 | self.log('Allowing redirect to: %s' % newURL) 539 | return allowRedirect() 540 | # If we're down here either the preference was set to 'none', 541 | # the url we're forwarding on to isn't https or follow_redirects 542 | # was explicitly set to False 543 | self.log('Denying redirect to: %s' % newURL) 544 | return denyRedirect() 545 | 546 | # we don't control the API, so 547 | # pylint: disable=too-many-arguments 548 | def URLSession_task_willPerformHTTPRedirection_newRequest_completionHandler_( 549 | self, _session, _task, response, request, completionHandler): 550 | '''NSURLSessionTaskDelegate method''' 551 | self.log( 552 | 'URLSession_task_willPerformHTTPRedirection_newRequest_' 553 | 'completionHandler_') 554 | if CALLBACK_HELPER_AVAILABLE: 555 | completionHandler.__block_signature__ = objc_method_signature(b'v@@') 556 | self.handleRedirect_newRequest_withCompletionHandler_( 557 | response, request, completionHandler) 558 | # pylint: enable=too-many-arguments 559 | 560 | def connection_willSendRequest_redirectResponse_( 561 | self, _connection, request, response): 562 | '''NSURLConnectionDataDelegate method 563 | Sent when the connection determines that it must change URLs in order 564 | to continue loading a request.''' 565 | self.log('connection_willSendRequest_redirectResponse_') 566 | return self.handleRedirect_newRequest_withCompletionHandler_( 567 | response, request, None) 568 | 569 | def connection_canAuthenticateAgainstProtectionSpace_( 570 | self, _connection, protectionSpace): 571 | '''NSURLConnection delegate method 572 | Sent to determine whether the delegate is able to respond to a 573 | protection space’s form of authentication. 574 | Deprecated in 10.10''' 575 | # this is not called in 10.5.x. 576 | self.log('connection_canAuthenticateAgainstProtectionSpace_') 577 | if protectionSpace: 578 | host = protectionSpace.host() 579 | realm = protectionSpace.realm() 580 | authenticationMethod = protectionSpace.authenticationMethod() 581 | self.log('Protection space found. Host: %s Realm: %s AuthMethod: %s' 582 | % (host, realm, authenticationMethod)) 583 | if self.username and self.password and authenticationMethod in [ 584 | 'NSURLAuthenticationMethodDefault', 585 | 'NSURLAuthenticationMethodHTTPBasic', 586 | 'NSURLAuthenticationMethodHTTPDigest']: 587 | # we know how to handle this 588 | self.log('Can handle this authentication request') 589 | return True 590 | # we don't know how to handle this; let the OS try 591 | self.log('Allowing OS to handle authentication request') 592 | return False 593 | 594 | def handleChallenge_withCompletionHandler_( 595 | self, challenge, completionHandler): 596 | '''Handle an authentication challenge''' 597 | protectionSpace = challenge.protectionSpace() 598 | host = protectionSpace.host() 599 | realm = protectionSpace.realm() 600 | authenticationMethod = protectionSpace.authenticationMethod() 601 | self.log( 602 | 'Authentication challenge for Host: %s Realm: %s AuthMethod: %s' 603 | % (host, realm, authenticationMethod)) 604 | if challenge.previousFailureCount() > 0: 605 | # we have the wrong credentials. just fail 606 | self.log('Previous authentication attempt failed.') 607 | if completionHandler: 608 | completionHandler( 609 | NSURLSessionAuthChallengeCancelAuthenticationChallenge, 610 | None) 611 | else: 612 | challenge.sender().cancelAuthenticationChallenge_(challenge) 613 | if self.username and self.password and authenticationMethod in [ 614 | 'NSURLAuthenticationMethodDefault', 615 | 'NSURLAuthenticationMethodHTTPBasic', 616 | 'NSURLAuthenticationMethodHTTPDigest']: 617 | self.log('Will attempt to authenticate.') 618 | self.log('Username: %s Password: %s' 619 | % (self.username, ('*' * len(self.password or '')))) 620 | credential = ( 621 | NSURLCredential.credentialWithUser_password_persistence_( 622 | self.username, self.password, 623 | NSURLCredentialPersistenceNone)) 624 | if completionHandler: 625 | completionHandler( 626 | NSURLSessionAuthChallengeUseCredential, credential) 627 | else: 628 | challenge.sender().useCredential_forAuthenticationChallenge_( 629 | credential, challenge) 630 | else: 631 | # fall back to system-provided default behavior 632 | self.log('Allowing OS to handle authentication request') 633 | if completionHandler: 634 | completionHandler( 635 | NSURLSessionAuthChallengePerformDefaultHandling, None) 636 | else: 637 | if (challenge.sender().respondsToSelector_( 638 | 'performDefaultHandlingForAuthenticationChallenge:')): 639 | self.log('Allowing OS to handle authentication request') 640 | challenge.sender( 641 | ).performDefaultHandlingForAuthenticationChallenge_( 642 | challenge) 643 | else: 644 | # Mac OS X 10.6 doesn't support 645 | # performDefaultHandlingForAuthenticationChallenge: 646 | self.log('Continuing without credential.') 647 | challenge.sender( 648 | ).continueWithoutCredentialForAuthenticationChallenge_( 649 | challenge) 650 | 651 | def connection_willSendRequestForAuthenticationChallenge_( 652 | self, _connection, challenge): 653 | '''NSURLConnection delegate method 654 | Tells the delegate that the connection will send a request for an 655 | authentication challenge. New in 10.7.''' 656 | self.log('connection_willSendRequestForAuthenticationChallenge_') 657 | self.handleChallenge_withCompletionHandler_(challenge, None) 658 | 659 | def URLSession_task_didReceiveChallenge_completionHandler_( 660 | self, _session, _task, challenge, completionHandler): 661 | '''NSURLSessionTaskDelegate method''' 662 | if CALLBACK_HELPER_AVAILABLE: 663 | completionHandler.__block_signature__ = objc_method_signature(b'v@i@') 664 | self.log('URLSession_task_didReceiveChallenge_completionHandler_') 665 | self.handleChallenge_withCompletionHandler_( 666 | challenge, completionHandler) 667 | 668 | def connection_didReceiveAuthenticationChallenge_( 669 | self, _connection, challenge): 670 | '''NSURLConnection delegate method 671 | Sent when a connection must authenticate a challenge in order to 672 | download its request. Deprecated in 10.10''' 673 | self.log('connection_didReceiveAuthenticationChallenge_') 674 | self.handleChallenge_withCompletionHandler_(challenge, None) 675 | 676 | def handleReceivedData_(self, data): 677 | '''Handle received data''' 678 | if self.destination: 679 | self.destination.write(data) 680 | else: 681 | try: 682 | self.log(str(data)) 683 | except Exception: 684 | pass 685 | self.bytesReceived += len(data) 686 | if self.expectedLength != NSURLResponseUnknownLength: 687 | # pylint: disable=old-division 688 | self.percentComplete = int( 689 | float(self.bytesReceived)/float(self.expectedLength) * 100.0) 690 | # pylint: enable=old-division 691 | 692 | def URLSession_dataTask_didReceiveData_(self, _session, _task, data): 693 | '''NSURLSessionDataDelegate method''' 694 | self.handleReceivedData_(data) 695 | 696 | def connection_didReceiveData_(self, _connection, data): 697 | '''NSURLConnectionDataDelegate method 698 | Sent as a connection loads data incrementally''' 699 | self.handleReceivedData_(data) 700 | 701 | 702 | if __name__ == '__main__': 703 | print('This is a library of support tools for the Munki Suite.') 704 | -------------------------------------------------------------------------------- /payload/Library/nudge/Resources/nudge: -------------------------------------------------------------------------------- 1 | #!/Library/ManagedFrameworks/Python/Python3.framework/Versions/Current/bin/python3 2 | # -*- coding: utf-8 -*- 3 | '''nudge - python wrapper for major OS updates.''' 4 | import json 5 | import optparse 6 | import os 7 | import platform 8 | import random 9 | import re 10 | import shutil 11 | import subprocess 12 | import sys 13 | import tempfile 14 | import time 15 | import urllib.request, urllib.parse, urllib.error 16 | import webbrowser 17 | from datetime import datetime, timedelta 18 | from distutils.version import LooseVersion 19 | from urllib.parse import urlparse, unquote 20 | import Foundation 21 | import objc 22 | from AppKit import * 23 | from CoreFoundation import CFPreferencesCopyAppValue, CFPreferencesSetAppValue, CFPreferencesAppSynchronize 24 | from SystemConfiguration import SCDynamicStoreCopyConsoleUser 25 | 26 | from nibbler import * 27 | import gurl 28 | 29 | 30 | class timerController(Foundation.NSObject): 31 | '''Thanks to frogor for help in figuring this part out''' 32 | def activateWindow_(self, timer_obj): 33 | determine_state_and_nudge() 34 | 35 | 36 | def determine_state_and_nudge(): 37 | '''Determine the state of nudge and re-fresh window''' 38 | workspace = NSWorkspace.sharedWorkspace() 39 | currently_active = NSApplication.sharedApplication().isActive() 40 | frontmost_app = workspace.frontmostApplication().bundleIdentifier() 41 | # Setup these globals as we will potentially override them 42 | global NUDGE_DISMISSED_COUNT 43 | global ACCEPTABLE_APPS 44 | if not currently_active and frontmost_app not in ACCEPTABLE_APPS: 45 | nudgelog('Nudge or acceptable applications not currently active') 46 | # If this is the under max dismissed count, just bring nudge back to the forefront 47 | # This is the old behavior 48 | if NUDGE_DISMISSED_COUNT < DISMISSAL_COUNT_THRESHOLD: 49 | nudgelog('Nudge dismissed count under threshold') 50 | NUDGE_DISMISSED_COUNT += 1 51 | bring_nudge_to_forefront() 52 | else: 53 | # Get more aggressive - new behavior 54 | nudgelog('Nudge dismissed count over threshold') 55 | NUDGE_DISMISSED_COUNT += 1 56 | nudgelog('Enforcing acceptable applications') 57 | # Loop through all the running applications 58 | for app in NSWorkspace.sharedWorkspace().runningApplications(): 59 | app_name = str(app.bundleIdentifier()) 60 | app_bundle = str(app.bundleURL()) 61 | if app_bundle: 62 | # The app bundle contains file://, quoted path and trailing slashes 63 | app_bundle_path = unquote(urlparse(app_bundle).path).rstrip('\/') 64 | # Add Software Update pane or macOS upgrade app to acceptable app list 65 | if app_bundle_path == PATH_TO_APP: 66 | ACCEPTABLE_APPS.append(app_name) 67 | else: 68 | # Some of the apps from NSWorkspace don't have bundles, so force empty string 69 | app_bundle_path = '' 70 | # Hide any apps that are not in acceptable list or are not the macOS upgrade app 71 | if (app_name not in ACCEPTABLE_APPS) or (app_bundle_path != PATH_TO_APP): 72 | app.hide() 73 | # Race condition with NSWorkspace. Python is faster :) 74 | time.sleep(0.001) 75 | # Another small sleep to ensure we can bring Nudge on top 76 | time.sleep(0.5) 77 | bring_nudge_to_forefront() 78 | # Pretend to open the button and open the update mechanism 79 | button_update(True) 80 | nudge.views['field.deferralcount'].setStringValue_(str(NUDGE_DISMISSED_COUNT)) 81 | 82 | 83 | def bring_nudge_to_forefront(): 84 | '''Brings nudge to the forefront - old behavior''' 85 | nudgelog('Nudge not active - Activating to the foreground') 86 | # We have to bring back python to the forefront since nibbler is a giant cheat 87 | NSApplication.sharedApplication().activateIgnoringOtherApps_(True) 88 | # Now bring the nudge window itself to the forefront 89 | # Nibbler objects have a .win property (...should probably be .window) 90 | # that contains a reference to the first NSWindow it finds 91 | nudge.win.makeKeyAndOrderFront_(None) 92 | 93 | 94 | def button_moreinfo(): 95 | '''Open browser more info button''' 96 | nudgelog('User clicked on more info button - opening URL in default browser') 97 | webbrowser.open_new_tab(MORE_INFO_URL) 98 | 99 | 100 | def button_update(simulated_click=False): 101 | '''Start the update process''' 102 | if simulated_click: 103 | nudgelog('Simulated click on update button - opening update application') 104 | else: 105 | nudgelog('User clicked on update button - opening update application') 106 | cmd = ['/usr/bin/open', PATH_TO_APP] 107 | subprocess.Popen(cmd) 108 | 109 | 110 | def button_ok(): 111 | '''Quit out of nudge if user hits the ok button''' 112 | nudgelog('User clicked on ok button - exiting application') 113 | nudge.quit() 114 | 115 | 116 | def button_understand(): 117 | '''Add an extra button to force the user to read the dialog, prior to being 118 | able to exit the UI.''' 119 | nudgelog('User clicked on understand button - enabling ok button') 120 | nudge.views['button.understand'].setHidden_(True) 121 | nudge.views['button.ok'].setHidden_(False) 122 | nudge.views['button.ok'].setEnabled_(True) 123 | nudge.views['button.understand'].setEnabled_(False) 124 | 125 | 126 | def downloadfile(options): 127 | '''download file with gurl''' 128 | connection = gurl.Gurl.alloc().initWithOptions_(options) 129 | percent_complete = -1 130 | bytes_received = 0 131 | connection.start() 132 | try: 133 | filename = options['name'] 134 | except KeyError: 135 | nudgelog(('No \'name\' key defined in json for %s' % 136 | pkgregex(options['file']))) 137 | sys.exit(1) 138 | 139 | try: 140 | while not connection.isDone(): 141 | if connection.destination_path: 142 | # only print progress info if we are writing to a file 143 | if connection.percentComplete != -1: 144 | if connection.percentComplete != percent_complete: 145 | percent_complete = connection.percentComplete 146 | nudgelog(('Downloading %s - Percent complete: %s ' % ( 147 | filename, percent_complete))) 148 | elif connection.bytesReceived != bytes_received: 149 | bytes_received = connection.bytesReceived 150 | nudgelog(('Downloading %s - Bytes received: %s ' % ( 151 | filename, bytes_received))) 152 | 153 | except (KeyboardInterrupt, SystemExit): 154 | # safely kill the connection then fall through 155 | connection.cancel() 156 | except Exception: # too general, I know 157 | # Let us out! ... Safely! Unexpectedly quit dialogs are annoying ... 158 | connection.cancel() 159 | # Re-raise the error 160 | raise 161 | 162 | if connection.error is not None: 163 | nudgelog(('Error: %s %s ' % (str(connection.error.code()), 164 | str(connection.error.localizedDescription())))) 165 | if connection.SSLerror: 166 | nudgelog('SSL error: %s ' % (str(connection.SSLerror))) 167 | if connection.response is not None: 168 | nudgelog('Status: %s ' % (str(connection.status))) 169 | nudgelog('Headers: %s ' % (str(connection.headers))) 170 | if connection.redirection != []: 171 | nudgelog('Redirection: %s ' % (str(connection.redirection))) 172 | 173 | 174 | def get_console_username_info(): 175 | '''Uses Apple's SystemConfiguration framework to get the current 176 | console username''' 177 | return SCDynamicStoreCopyConsoleUser(None, None, None) 178 | 179 | 180 | def get_os_sub_build_version(): 181 | '''Return sub build of macOS''' 182 | cmd = ['/usr/sbin/sysctl', '-n', 'kern.osversion'] 183 | run = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 184 | output = run.communicate()[0] 185 | return LooseVersion(output.decode("utf-8").strip()) 186 | 187 | 188 | def get_os_version(): 189 | '''Return OS version.''' 190 | return LooseVersion(platform.mac_ver()[0]) 191 | 192 | 193 | def get_os_version_major(): 194 | '''Return major OS version.''' 195 | full_os = platform.mac_ver()[0] 196 | # Handle Big Sur and higher since major version is now the first portion 197 | split_os = full_os.split('.') 198 | if LooseVersion(split_os[0]) >= LooseVersion('11'): 199 | return LooseVersion(split_os[0]) 200 | # Sometimes the OS version will return without the dot release 201 | # For example, it may show as 10.15.0 instead of 10.15 202 | if len(split_os) == 3: 203 | return LooseVersion(full_os.rsplit('.', 1)[0]) 204 | elif len(split_os) == 2: 205 | return LooseVersion(full_os) 206 | else: 207 | nudgelog('Cannot reliably determine OS major version. Exiting...') 208 | exit(1) 209 | 210 | 211 | def get_parsed_options(): 212 | '''Return the parsed options and args for this application.''' 213 | # Options 214 | usage = '%prog [options]' 215 | options = optparse.OptionParser(usage=usage) 216 | options.add_option('--headers', help=('Optional: Auth headers')) 217 | options.add_option('--jsonurl', help=('Required: URL to json file.')) 218 | return options.parse_args() 219 | 220 | 221 | def get_serial(): 222 | '''Get system serial number''' 223 | # Credit to Michael Lynn 224 | IOKit_bundle = Foundation.NSBundle.bundleWithIdentifier_('com.apple.framework.IOKit') 225 | 226 | functions = [("IOServiceGetMatchingService", b"II@"), 227 | ("IOServiceMatching", b"@*"), 228 | ("IORegistryEntryCreateCFProperty", b"@I@@I"), 229 | ] 230 | 231 | objc.loadBundleFunctions(IOKit_bundle, globals(), functions) 232 | # pylint: disable=undefined-variable 233 | serial = IORegistryEntryCreateCFProperty( 234 | IOServiceGetMatchingService( 235 | 0, 236 | IOServiceMatching( 237 | "IOPlatformExpertDevice".encode("utf-8") 238 | )), 239 | Foundation.NSString.stringWithString_("IOPlatformSerialNumber"), 240 | None, 241 | 0) 242 | # pylint: enable=undefined-variable 243 | return serial 244 | 245 | 246 | def load_nudge_globals(): 247 | '''Try to figure out the path of nudge.nib and load it.''' 248 | try: 249 | # Setup our global nudge variable to inject into our nib file 250 | global nudge 251 | nudge = Nibbler(os.path.join(NUDGE_PATH, 'nudge.nib')) 252 | except IOError: 253 | nudgelog('Unable to load nudge nib file!') 254 | exit(20) 255 | 256 | 257 | def nudge_already_loaded(): 258 | '''Check if nudge is already loaded''' 259 | nudge_string = '/Library/nudge/Resources/nudge' 260 | cmd = ['/bin/ps', '-o', 'pid', '-o', 'command'] 261 | run = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 262 | output, err = run.communicate() 263 | status = output.split(b'\n') 264 | current_pid = str(os.getpid()) 265 | for line in status: 266 | if bytes(nudge_string, 'utf-8') in line: 267 | if bytes(current_pid, 'utf-8') in line: 268 | pass 269 | else: 270 | return True 271 | return False 272 | 273 | 274 | def nudgelog(text): 275 | '''logger for nudge''' 276 | Foundation.NSLog('[Nudge] ' + text) 277 | 278 | 279 | def pref(pref_name, domain='com.erikng.nudge'): 280 | """Returns a preference from the specified domain. 281 | 282 | Uses CoreFoundation. 283 | 284 | Args: 285 | pref_name: str preference name to get. 286 | """ 287 | pref_value = CFPreferencesCopyAppValue( 288 | pref_name, domain) 289 | if isinstance(pref_value, Foundation.NSDate): 290 | # convert NSDate/CFDates to strings 291 | pref_value = str(pref_value) 292 | return pref_value 293 | 294 | 295 | def set_pref(pref_name, value, domain='com.erikng.nudge'): 296 | """Sets a value in Preferences. 297 | Uses CoreFoundation. 298 | Args: 299 | pref_name: str preference name to set. 300 | value: value to set it to. 301 | """ 302 | CFPreferencesSetAppValue(pref_name, value, domain) 303 | CFPreferencesAppSynchronize(domain) 304 | 305 | 306 | def download_apple_updates(): 307 | '''Download everything Softwareupdate has to offer''' 308 | 309 | cmd = [ 310 | '/usr/sbin/softwareupdate', 311 | '-da' 312 | ] 313 | 314 | try: 315 | return subprocess.check_output(cmd) 316 | except subprocess.CalledProcessError: 317 | return None 318 | 319 | 320 | def pending_apple_updates(): 321 | '''Pending apple updates 322 | Returns a dict of pending updates''' 323 | 324 | return pref('RecommendedUpdates', 'com.apple.SoftwareUpdate') 325 | 326 | 327 | def update_app_path(): 328 | software_updates_prefpane = '/System/Library/PreferencePanes/SoftwareUpdate.prefPane' 329 | if os.path.exists(software_updates_prefpane): 330 | return 'file://{}'.format(software_updates_prefpane) 331 | else: 332 | return 'macappstore://showUpdatesPage' 333 | 334 | 335 | def pkgregex(pkgpath): 336 | '''regular expression for pkg''' 337 | try: 338 | # capture everything after last / in the pkg filepath 339 | pkgname = re.compile(r"[^/]+$").search(pkgpath).group(0) 340 | return pkgname 341 | except AttributeError as IndexError: 342 | return pkgpath 343 | 344 | 345 | def get_minimum_minor_update_days(update_minor_days, pending_apple_updates, nudge_su_prefs): 346 | '''Lowest number of days before something is forced''' 347 | if pending_apple_updates == [] or pending_apple_updates is None: 348 | return update_minor_days 349 | 350 | lowest_days = update_minor_days 351 | todays_date = datetime.utcnow() 352 | for item in nudge_su_prefs: 353 | for update in pending_apple_updates: 354 | if str(item['name']) == str(update['Product Key']): 355 | nudgelog('{} has a forced date'.format(update['Product Key'])) 356 | force_date_strp = datetime.strptime(item['force_install_date'], '%Y-%m-%d-%H:%M') 357 | date_diff_seconds = (force_date_strp - todays_date).total_seconds() 358 | date_diff_days = int(round(date_diff_seconds / 86400)) 359 | if date_diff_days < lowest_days: 360 | lowest_days = date_diff_days 361 | 362 | return lowest_days 363 | 364 | 365 | def main(): 366 | '''Main thread''' 367 | opts, _ = get_parsed_options() 368 | 369 | if nudge_already_loaded(): 370 | nudgelog('nudge already loaded!') 371 | exit(0) 372 | 373 | # Get the current username 374 | user_name, current_user_uid, _ = get_console_username_info() 375 | 376 | # Bail if we are not in a user session. 377 | if user_name in (None, 'loginwindow', '_mbsetupuser'): 378 | exit(0) 379 | 380 | # Setup our globals to use across nibbler and nibbler functions 381 | global DISMISSAL_COUNT_THRESHOLD 382 | global NUDGE_PATH 383 | global MORE_INFO_URL 384 | global PATH_TO_APP 385 | global NUDGE_DISMISSED_COUNT 386 | global ACCEPTABLE_APPS 387 | 388 | # Figure out the local path of nudge 389 | NUDGE_PATH = os.path.dirname(os.path.realpath(__file__)) 390 | 391 | # Part for enhanced enforcement of Nudge 392 | NUDGE_DISMISSED_COUNT = 0 393 | ACCEPTABLE_APPS = [ 394 | 'com.apple.loginwindow', 395 | 'com.apple.systempreferences', 396 | 'org.python.python' 397 | ] 398 | 399 | # local json path - if it exists already, let's assume someone is bundling 400 | # it with their package. Otherwise check for it and use gurl. 401 | json_path = os.path.join(NUDGE_PATH, 'nudge.json') 402 | cleanup = True 403 | if os.path.isfile(json_path): 404 | cleanup = False 405 | json_raw = open(json_path).read() 406 | else: 407 | tmp_dir = tempfile.mkdtemp() 408 | tmp_json = os.path.join(tmp_dir, 'nudge.json') 409 | json_path = tmp_json 410 | json_raw = None 411 | if opts.jsonurl: 412 | json_url = opts.jsonurl 413 | # json data for gurl download 414 | json_data = { 415 | 'url': json_url, 416 | 'file': json_path, 417 | 'name': 'nudge.json' 418 | } 419 | 420 | # Grab auth headers if they exist and update the json_data dict. 421 | if opts.headers: 422 | headers = {'Authorization': opts.headers} 423 | json_data.update({'additional_headers': headers}) 424 | 425 | url_parse = urllib.parse.urlparse(json_url) 426 | if url_parse.scheme == 'file': 427 | # File resources should be handled natively 428 | try: 429 | json_raw = urllib.request.urlopen(json_url).read() 430 | except urllib.error.URLError as err: 431 | nudgelog(err) 432 | shutil.rmtree(tmp_dir) 433 | exit(1) 434 | else: 435 | # If the file doesn't exist, grab it and wait half a second to save. 436 | while not os.path.isfile(json_path): 437 | nudgelog(('Starting download: %s' % (urllib.parse.unquote( 438 | json_data['url'])))) 439 | downloadfile(json_data) 440 | time.sleep(0.5) 441 | else: 442 | nudgelog('nudge JSON file not specified!') 443 | shutil.rmtree(tmp_dir) 444 | exit(1) 445 | 446 | # Load up file to grab all the items. 447 | if json_raw: 448 | nudge_json = json.loads(json_raw) 449 | else: 450 | nudge_json = json.loads(open(json_path).read()) 451 | 452 | # Load nudge preferences 453 | nudge_prefs = nudge_json['preferences'] 454 | # Setup nudge preferences and all defaults if not set 455 | button_title_text = nudge_prefs.get('button_title_text', 456 | 'Ready to start the update?') 457 | button_sub_titletext = nudge_prefs.get('button_sub_titletext', 458 | 'Click on the button below.') 459 | cut_off_date = nudge_prefs.get('cut_off_date', False) 460 | cut_off_date_warning = nudge_prefs.get('cut_off_date_warning', 3) 461 | days_between_notifications = nudge_prefs.get('days_between_notifications', 462 | 0) 463 | DISMISSAL_COUNT_THRESHOLD = nudge_prefs.get('dismissal_count_threshold', 9999999) 464 | logo_path = nudge_prefs.get('logo_path', 'company_logo.png') 465 | main_subtitle_text = nudge_prefs.get('main_subtitle_text', 466 | 'A friendly reminder from your local IT team') 467 | main_title_text = nudge_prefs.get('main_title_text', 'macOS Update') 468 | minimum_os_sub_build_version = nudge_prefs.get('minimum_os_sub_build_version', '10A00') 469 | minimum_os_version = nudge_prefs.get('minimum_os_version', '10.14.6') 470 | minimum_os_version_major = minimum_os_version.rsplit('.', 1)[0] 471 | MORE_INFO_URL = nudge_prefs.get('more_info_url', False) 472 | no_timer = nudge_prefs.get('no_timer', False) 473 | paragraph1_text = nudge_prefs.get('paragraph1_text', 474 | 'A fully up-to-date device is required to ensure that IT can your accurately protect your computer.') 475 | paragraph2_text = nudge_prefs.get('paragraph2_text', 476 | 'If you do not update your computer, you may lose access to some items necessary for your day-to-day tasks.') 477 | paragraph3_text = nudge_prefs.get('paragraph3_text', 478 | 'To begin the update, simply click on the button below and follow the provided steps.') 479 | paragraph_title_text = nudge_prefs.get('paragraph_title_text', 480 | 'A security update is required on your machine.') 481 | PATH_TO_APP = nudge_prefs.get('path_to_app', 482 | '/Applications/Install macOS Mojave.app') 483 | screenshot_path = nudge_prefs.get('screenshot_path', 'update_ss.png') 484 | LOCAL_URL_FOR_UPGRADE = nudge_prefs.get('local_url_for_upgrade', False) 485 | timer_day_1 = nudge_prefs.get('timer_day_1', 600) 486 | timer_day_3 = nudge_prefs.get('timer_day_3', 7200) 487 | timer_elapsed = nudge_prefs.get('timer_elapsed', 10) 488 | timer_final = nudge_prefs.get('timer_final', 60) 489 | timer_initial = nudge_prefs.get('timer_initial', 14400) 490 | random_delay = nudge_prefs.get('random_delay', False) 491 | nudge_su_prefs = nudge_json.get('software_updates', []) 492 | update_minor = nudge_prefs.get('update_minor', False) 493 | update_minor_days = nudge_prefs.get('update_minor_days', 14) 494 | 495 | # Start information 496 | nudgelog('Target OS version: %s ' % minimum_os_version) 497 | if update_minor: 498 | if minimum_os_sub_build_version == '10A00': 499 | update_minor = False 500 | else: 501 | nudgelog('Target OS subversion: %s' % minimum_os_sub_build_version) 502 | nudgelog('Dismissal count threshold: %s ' % DISMISSAL_COUNT_THRESHOLD) 503 | 504 | 505 | # cleanup the tmp stuff now 506 | if cleanup: 507 | nudgelog('Cleaning up temporary files...') 508 | shutil.rmtree(tmp_dir) 509 | 510 | if random_delay: 511 | delay = random.randint(1,1200) 512 | nudgelog('Delaying run for {} seconds...'.format(delay)) 513 | time.sleep(delay) 514 | 515 | # If the admin put '10.14' and not '10.14.0' the major version will be '10' 516 | # so make sure this error doesn't happen and the comparison doesn't fail. 517 | if '.' not in minimum_os_version_major: 518 | minimum_os_version_major = minimum_os_version 519 | 520 | # Handle Big Sur and higher since major version is now the first portion 521 | split_minimum_os_version_major = minimum_os_version_major.split('.') 522 | if LooseVersion(split_minimum_os_version_major[0]) >= LooseVersion('11'): 523 | minimum_os_version_major = split_minimum_os_version_major[0] 524 | 525 | os_version = get_os_version() 526 | os_version_major = get_os_version_major() 527 | os_version_sub_build = get_os_sub_build_version() 528 | 529 | # Bail if python framework was not built on Big Sur 530 | if os_version_major == '10.16': 531 | nudgelog('Detected Big Sur running version 10.16. Nudge cannot be '\ 532 | 'reliably enforced. To fix this, create a Python.framework '\ 533 | 'file on a machine running Big Sur or higher.') 534 | exit(1) 535 | 536 | # Example 10.14.6 (18G103) >= 10.14.6 (18G84) 537 | if os_version_sub_build >= LooseVersion(minimum_os_sub_build_version) and update_minor: 538 | nudgelog('OS version sub build is higher or equal to the minimum threshold: %s' % str(os_version_sub_build)) 539 | exit(0) 540 | # Example: 10.14.6 >= 10.14.6 541 | elif os_version >= LooseVersion(minimum_os_version) and not update_minor: 542 | nudgelog('OS version is higher or equal to the minimum threshold: %s' % str(os_version)) 543 | exit(0) 544 | # Example: 10.14/10.14.0 >= 10.14 545 | elif os_version_major >= LooseVersion(minimum_os_version_major) and not update_minor: 546 | nudgelog('OS major version is higher or equal to the minimum threshold and minor updates not enabled: %s ' % str(os_version)) 547 | exit(0) 548 | else: 549 | nudgelog('OS version is below the minimum threshold: %s' % str(os_version)) 550 | if update_minor and LooseVersion(minimum_os_sub_build_version) > os_version_sub_build: 551 | nudgelog('OS version is below the minimum threshold subversion: %s' % str(os_version_sub_build)) 552 | 553 | minor_updates_required = False 554 | 555 | # Start main logic on major and minor upgrades 556 | if LooseVersion(minimum_os_version_major) > os_version_major: 557 | # This is a major upgrade now and needs the app. We shouldn't 558 | # perform minor updates. 559 | if LOCAL_URL_FOR_UPGRADE: 560 | # Reassign the global PATH_TO_APP with the specified local 561 | # upgrade URL 562 | PATH_TO_APP = LOCAL_URL_FOR_UPGRADE 563 | else: 564 | if not os.path.exists(PATH_TO_APP): 565 | nudgelog('Update application not found! Exiting...') 566 | exit(1) 567 | else: 568 | # do minor version stuff 569 | if update_minor: 570 | nudgelog('Checking for minor updates.') 571 | swupd_output = download_apple_updates() 572 | if not swupd_output: 573 | nudgelog('Could not run softwareupdate') 574 | # Exit 0 as we might be offline 575 | # TODO: Check if we're offline to exit with the 576 | # appropriate code 577 | exit(0) 578 | 579 | if pending_apple_updates() == [] or pending_apple_updates() is None: 580 | nudgelog('No Software updates to install') 581 | set_pref('first_seen', None) 582 | set_pref('last_seen', None) 583 | exit(0) 584 | else: 585 | # There are pending updates 586 | first_seen = pref('first_seen') 587 | last_seen = pref('last_seen') 588 | PATH_TO_APP = update_app_path() 589 | 590 | apple_sus_prefs_path = '/Library/Preferences/com.apple.SoftwareUpdate' 591 | 592 | if pref('AutomaticCheckEnabled', apple_sus_prefs_path) and \ 593 | pref('AutomaticDownload', apple_sus_prefs_path) and \ 594 | pref('AutomaticallyInstallMacOSUpdates', apple_sus_prefs_path): 595 | # Only care about updates needing a restart 596 | swupd_output = subprocess.check_output(['/usr/sbin/softwareupdate', '-la']) 597 | for line in swupd_output.splitlines(): 598 | if b'restart' in line.lower(): 599 | minor_updates_required = True 600 | break 601 | else: 602 | # required preferences for background updates aren't present, notify for all 603 | minor_updates_required = True 604 | 605 | if not minor_updates_required: 606 | nudgelog('Only updates that can be installed in the background pending.') 607 | set_pref('first_seen', None) 608 | set_pref('last_seen', None) 609 | exit() 610 | # todo: Work out how long the user has to install it 611 | # todays_date = datetime.utcnow() 612 | # first_seen_strp = datetime.strptime(first_seen, '%Y-%m-%d %H:%M:%S +0000') 613 | # date_diff_seconds = (first_seen_strp - todays_date).total_seconds() 614 | # date_diff_days = int(round(date_diff_seconds / 86400)) 615 | # print date_diff_days 616 | 617 | # Allow admin to not show nudge all the time 618 | if days_between_notifications > 0: 619 | if first_seen and last_seen: 620 | today = datetime.utcnow() 621 | last_seen_strp = datetime.strptime(last_seen, '%Y-%m-%d %H:%M:%S +0000') 622 | difference = today - last_seen_strp 623 | nudgelog(str(difference.days)) 624 | if difference.days < days_between_notifications: 625 | nudgelog('Last seen date is within notification threshold: %s ' % str(days_between_notifications)) 626 | exit(0) 627 | 628 | if not first_seen: 629 | set_pref('first_seen', datetime.utcnow()) 630 | first_seen = pref('first_seen') 631 | 632 | load_nudge_globals() 633 | 634 | # Use the paths defined, or default to pngs in the same local path of 635 | # nudge 636 | for index, path in enumerate([logo_path, screenshot_path]): 637 | if path in ('company_logo.png', 'update_ss.png'): 638 | local_png_path = os.path.join( 639 | NUDGE_PATH, path).replace(' ', '%20') 640 | else: 641 | local_png_path = os.path.join(path).replace(' ', '%20') 642 | foundation_nsurl_path = Foundation.NSURL.URLWithString_( 643 | 'file:' + local_png_path) 644 | foundation_nsdata = Foundation.NSData.dataWithContentsOfURL_( 645 | foundation_nsurl_path) 646 | foundation_nsimage = NSImage.alloc().initWithData_( 647 | foundation_nsdata) 648 | if index == 0: 649 | nudge.views['image.companylogo'].setImage_(foundation_nsimage) 650 | else: 651 | nudge.views['image.updatess'].setImage_(foundation_nsimage) 652 | 653 | # Attach all the nib buttons to functions 654 | nudge.attach(button_update, 'button.update') 655 | nudge.attach(button_moreinfo, 'button.moreinfo') 656 | nudge.attach(button_ok, 'button.ok') 657 | nudge.attach(button_understand, 'button.understand') 658 | 659 | # Setup the UI fields 660 | nudge.views['field.titletext'].setStringValue_(main_title_text) 661 | nudge.views['field.subtitletext'].setStringValue_(main_subtitle_text) 662 | nudge.views['field.updatetext'].setStringValue_(paragraph_title_text) 663 | nudge.views['field.paragraph1'].setStringValue_(paragraph1_text) 664 | nudge.views['field.paragraph2'].setStringValue_(paragraph2_text) 665 | nudge.views['field.paragraph3'].setStringValue_(paragraph3_text) 666 | nudge.views['field.h1text'].setStringValue_(button_title_text) 667 | nudge.views['field.h2text'].setStringValue_(button_sub_titletext) 668 | 669 | # Dynamically set username and serialnumber 670 | nudge.views['field.username'].setStringValue_(str(user_name)) 671 | nudge.views['field.serialnumber'].setStringValue_(str(get_serial())) 672 | nudge.views['field.updated'].setStringValue_('No') 673 | 674 | # Hide the MORE_INFO_URL if it's not set 675 | if not MORE_INFO_URL: 676 | nudge.views['button.moreinfo'].setHidden_(True) 677 | 678 | minimum_minor_update_days = get_minimum_minor_update_days(update_minor_days, pending_apple_updates(), nudge_su_prefs) 679 | if cut_off_date or (minor_updates_required and minimum_minor_update_days > 0): 680 | todays_date = datetime.utcnow() 681 | if not cut_off_date: # fix for minor updates logic 682 | cut_off_date_strp = todays_date + timedelta(days=minimum_minor_update_days) 683 | else: 684 | cut_off_date_strp = datetime.strptime(cut_off_date, '%Y-%m-%d-%H:%M') 685 | date_diff_seconds = (cut_off_date_strp - todays_date).total_seconds() 686 | date_diff_days = int(round(date_diff_seconds / 86400)) 687 | 688 | if date_diff_seconds >= 0: 689 | nudge.views['field.daysremaining'].setStringValue_( 690 | date_diff_days) 691 | else: 692 | nudge.views['field.daysremaining'].setStringValue_( 693 | 'Past date!') 694 | 695 | cut_off_warn = bool(date_diff_seconds < int( 696 | cut_off_date_warning) * 86400) 697 | 698 | # Setup our timer controller 699 | nudge.timer_controller = timerController.alloc().init() 700 | 701 | if date_diff_seconds <= 0: 702 | # If the cutoff date is over, get stupidly aggressive 703 | 704 | # Disable all buttons so the user cannot exit out of the 705 | # application, and have the manualenrollment button appear 706 | nudge.views['button.ok'].setHidden_(True) 707 | nudge.views['button.understand'].setHidden_(True) 708 | 709 | # Bring back nudge to the foreground, every 10 seconds 710 | timer = float(timer_elapsed) 711 | elif date_diff_seconds <= 3600: 712 | # If the cutoff date is within one hour, get very agressive 713 | 714 | # Disable all buttons so the user cannot exit out of the 715 | # application 716 | nudge.views['button.ok'].setHidden_(True) 717 | nudge.views['button.understand'].setHidden_(True) 718 | 719 | # Bring back nudge to the foreground, every 60 seconds 720 | # (1 minute) 721 | timer = float(timer_final) 722 | elif date_diff_seconds <= 86400: 723 | # If the cutoff date is within 86,400 seconds (24 hours), start 724 | # getting more agressive 725 | 726 | # Disable the ok button and require users to press understand 727 | # button first 728 | nudge.views['button.understand'].setHidden_(False) 729 | nudge.views['button.understand'].setEnabled_(True) 730 | nudge.views['button.ok'].setHidden_(True) 731 | 732 | # If the user doesn't close out of nudge, we want it to 733 | # reappear - bring back nudge to the foreground, every 734 | # 600 seconds (10 minutes) 735 | timer = float(timer_day_1) 736 | elif cut_off_warn: 737 | # If the cutoff date is within 259,200 seconds (72 hours) or 738 | # whatever the admin set, start getting a bit more agressive 739 | 740 | # Disable the ok button and require users to press understand 741 | # button first 742 | nudge.views['button.understand'].setHidden_(False) 743 | nudge.views['button.understand'].setEnabled_(True) 744 | nudge.views['button.ok'].setHidden_(True) 745 | 746 | # If the user doesn't close out of nudge, we want it to 747 | # reappear - bring back nudge to the foreground, every 748 | # 7,200 seconds (2 hours) 749 | timer = float(timer_day_3) 750 | else: 751 | # If the cutoff date is over 259,200 seconds (72 hours), 752 | # don't be that aggressive 753 | 754 | # Only require the ok button to exit out of nudge 755 | nudge.views['button.ok'].setHidden_(False) 756 | nudge.views['button.ok'].setEnabled_(True) 757 | nudge.views['button.understand'].setHidden_(True) 758 | 759 | # If the user doesn't close out of nudge, we want it to 760 | # reappear - bring back nudge to the foreground, every 761 | # 14,400 seconds (4 hours) 762 | timer = float(timer_initial) 763 | 764 | nudge.timer = ( 765 | Foundation 766 | .NSTimer 767 | .scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( 768 | timer, nudge.timer_controller, 'activateWindow:', None, True)) 769 | else: 770 | # If you elect not to use a cutoff date, then the UI will only 771 | # appear one time per run, and only use the ok button 772 | 773 | # Hide the fields used for the cutoff date 774 | nudge.views['field.daysremainingtext'].setHidden_(True) 775 | nudge.views['field.daysremaining'].setHidden_(True) 776 | 777 | # Only require the ok button to exit out of nudge 778 | nudge.views['button.ok'].setHidden_(False) 779 | nudge.views['button.ok'].setEnabled_(True) 780 | nudge.views['button.understand'].setHidden_(True) 781 | 782 | timer = float(timer_day_3) 783 | date_diff_seconds = 1000000 784 | 785 | # Use cut off dates, but don't use the timer functionality 786 | if no_timer: 787 | nudge.timer.invalidate() 788 | nudgelog('Timer invalidated!') 789 | else: 790 | nudgelog('Timer is set to %s' % str(timer)) 791 | 792 | # Set last_seen pref 793 | set_pref('last_seen', datetime.utcnow()) 794 | last_seen = pref('last_seen') 795 | 796 | # Set up our window controller and delegate 797 | nudge.hidden = True 798 | nudge.run() 799 | 800 | 801 | if __name__ == '__main__': 802 | main() 803 | --------------------------------------------------------------------------------