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