-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathswiftpatch.py
More file actions
executable file
·2473 lines (2012 loc) · 89.5 KB
/
swiftpatch.py
File metadata and controls
executable file
·2473 lines (2012 loc) · 89.5 KB
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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
SwiftPatch
Author: Matt Hrono @ Chime | MacAdmins: @matt_h | mattonmacs.dev
----
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
----
Some elements and inspiration derived from
- Tom Larkin's Auto-Update: https://github.com/t-lark/Auto-Update/ | Copyright 2019 Snowflake Inc. | Apache 2.0 License
- My fork of Auto-Update: https://github.com/mhrono/Auto-Update/ | Copyright 2022 Buoy Health, Inc. | Apache 2.0 License
"""
"""
REQUIREMENTS:
- swiftDialog: https://github.com/bartreardon/swiftDialog
- Version 2.3.0 or higher
- PyObjC -- I suggest the "recommended" flavor of MacAdmins Python: https://github.com/macadmins/python
- This recommended Python package also includes any other modules this script requires
- Be sure to update the shebang to point to your managed installation
NOTICE:
- This script is intended to be used alongside AutoPkg, Jamf-Upload, and my non-standard jamf recipes available at https://github.com/mhrono/autopkg-recipes
- While it may work with other workflows, platforms, or environments, it is not supported and I make no guarantees of functionality
- You are, however, free to modify this script to suit your needs under the terms of the Apache 2.0 License detailed above
- At a BARE MINIMUM, this script as written will expect configuration profiles in place containing all fields referenced within
ACKNOWLEDGEMENTS:
I would be in remiss if I did not acknowledge a number of folks from MacAdmins Slack who have provided inspiration, pointers, and assistance while creating this workflow. In no particular order:
- @tlark
- @grahamrpugh
- @nick.mcspadden
- @bartreardon
- @BigMacAdmin
- @drtaru
- @adamcodega
- @dan-snelson
- All the other fine folks in #autopkg, #python, #jamf-upload, and #swiftdialog
Finally, thanks to the authors and commenters on an untold number of StackOverflow theads.
"""
"""
Use SwiftDialog to prompt users to update their apps
Version rollback functionality is not yet implemented. Some of the bits are already written, but no testing has been done and rollbacks are not expected to work.
"""
scriptVersion = "1.2.0"
requiredDialogVersionString = "2.5.0"
requiredDialogPattern = r"^(\d{2,}.*|[3-9].*|2\.\d{2,}.*|2\.[6-9].*|2\.5\.\d{2,}.*|2\.5\.[1-9].*|2\.5\.0.*)$"
requiredPythonVersionString = "3.12"
requiredPythonPattern = (
r"^(\d{2,}.*|[4-9].*|3\.\d{3,}.*|3\.[2-9]\d{1,}.*|3\.1[3-9].*|3\.12.*)$"
)
"""
CHANGELOG:
---- 1.0 RC1 | 2023-09-22 ----
First functional release
---- 1.0 RC2 | 2023-09-26 ----
Remove display of deferral data unless <= 3 remaining
Adjust dialog json options to make windows slightly more compact
Change macOS installed version logic to account for new major releases
---- 1.0 RC3 | 2023-10-04 ----
Add import for urllib.parse
Adjust pkgName variable assignment in monitorPolicyRun to account for HTML-encoded spaces in filenames
---- 1.0 | 2023-10-05 ----
First public release!
---- 1.0.1 | 2023-10-13 ----
Move run receipt update above inventory update in cleanup
---- 1.0.2 | 2023-10-18 ----
Remove unneeded warning logging for silent status update attempts
Fix path presence checking in getBinaryVersion
Added some error handling to getBinaryVersion
Fixed force tagging logic for silent updates
---- 1.0.3 | 2023-10-23 ----
Added step to unload existing LaunchDaemons before loading new ones
Changed run time in receipt to use UTC instead of device local time
Added RunAtLoad to setup LaunchDaemons
---- 1.0.4 | 2023-10-24 ----
Fixed issue with last run receipt checking
---- 1.1.0 | 2023-11-06 ----
POTENTIALLY BREAKING CHANGES
----------------------------
Setting preferences via configuration profile is now possible using the com.github.swiftpatch domain
Any NEW preference keys will be available via configuration profile, URL, or json file ONLY, with the exception of the new --selfservice option
NO additional command-line arguments will be added to support additional keys
All options are supported, and command-line arguments will continue to take precedence over other methods
For a list of available command-line arguments, run this script with --help
----------------------------
Now, on to the changes:
Updated description for --saveprefs argument to remind that configuration profiles take precedence
Added --selfservice argument to enable an ultralight single-app update interface
Added endRun function to consolidate script exit points and ensure consistency
Added support for preferences set by configuration profile
Added 'simple' option to remove the info box, icon, and instructions, creating a smaller and cleaner prompt interface
Added 'speedtest' option to enable or disable the download speed test (defaults to True, unless 'simple' is True)
Added 'position' option to configure where on the user's screen the prompt is generated
Added 'speechBalloon' emoji
Added check for existing dialog processes before continuing a run to avoid a potential race condition
Updated 'updateStatus' function to handle updates to 'selfservice' runs
Updated retrieval method for 'systemUptimeDays' in 'getDeviceHealth' to include time spent sleeping
Fixed copy in infobox uptime field to remove use the singular 'day' if 'systemUptimeDays == 1'
Fixed an issue in 'setDeferral' by adding an item to 'deferralIndexMap' to better handle randomized deferrals
Updated method for gathering installed update metadata profiles to enable case-insensitivity (and avoid potential issues when using '--selfservice')
Fixed version metadata being unintentionally included in 'dialogPromptList'
Added status overlay icons to dialog updates for a slightly richer UX
Updated time between download progress checks from 0.5 > 0.1 seconds, also for a slightly richer UX
Updated some copy and buttom labeling for both the prompt and status dialogs when using the default interface
Added copy and dialog parameters to prompt and status dialogs to support new simple interface
Fixed typo in 'parseUserSelections' function name
Added logic in 'run' to process 'selfservice' executions
Fixed lazy deferral checking in 'parseUserSelections' to only check app keys in 'promptOutput'
Other minor fixes throughout
---- 1.1.1 | 2023-11-13 ----
Fixed and reformatted arguments and prefsData processing (thanks @erchn!)
Fixed processing of --selfservice argument in multiple places
Removed a placeholder comment
Updated formatting with Black for improved readability (also thanks to @erchn)
Removed backticks from 1.1.0 release notes--look great on GitHub, behave rudely when writing this script to disk from a shell script
---- 1.1.2 | 2024-01-19 ----
Fixed missing logging of preference detection
Fixed bug with preference loading when a configuration profile and preferences file are present
Added functionality to removeDaemons to accept and remove a single daemon by path
Added missing silent argument to preferences file creation in setPrefsFile
Updated setDeferral and setupRunSchedule functions to handle daemon creation using configuration profile preferences
Updated setupRunSchedule to remove existing daemons if present
---- 1.1.3 | 2024-04-15 ----
Fixed some syntax warnings with regex patterns by marking them as raw strings
Updated download-tracking refresh timing and added percentage display
Updated progress bar behavior to better match reality
Updated messaging for completed Self Service updates
Minor formatting updates
---- 1.1.4 | 2024-04-16 ----
Fix mishandling of empty version regex patterns
---- 1.1.5 | 2024-04-18 ----
Added imports for new functions
Update method for retrieving current user data to use native methods
Added checkDisplaySleep function to determine if all displays are sleeping
Added checkScreenLocked function to determine if the screen is locked
Added both new functions to the list of potential interruption reasons in checkInterruptions
Minor formatting updates
---- 1.1.6 | 2024-04-24 ----
Fixed bug when attempting to check the version of a missing binary
Fixed TypeError on userUID when attempting to relaunch apps
---- 1.1.7 | 2024-05-13 ----
Fixed bug causing a failure when a policy executes a script prior to package download
Added a timeout to recon submission
---- 1.1.8 | 2024-05-17 ----
Fixed bug when attempting to update an app that is no longer installed
Added call to checkInstallDate, which was unintentionally not referenced
Added logging output for current script version
---- 1.1.9 | 2024-05-21 ----
Added forced update support to --selfservice
---- 1.1.10 | 2024-05-22 ----
Fixed missing argument when calling checkInstallDate
Fixed updates not being forced if no deferrals remain
Added user-facing details to the --selfservice prompt when an update is forced
Fixed forced --selfservice updates not being forced under certain conditions
---- 1.1.11 | 2024-05-22 ----
Fixed bug unintentionally showing infobutton
---- 1.1.12 | 2024-05-23 ----
Updated required swiftDialog version to 2.3.3
Changed online macOS versioning source from Apple catalog to SOFA (https://sofa.macadmins.io)
Updated macOS version validation regex for some futureproofing
Added additional dialog overlay icons during update processing for improved UX
Changed boolean dialog options from 0/1 integers to True/False
Minor formatting updates
---- 1.1.13 | 2024-06-24 ----
Updated required swiftDialog version to 2.5.0
Updated required python version to 3.12
Added a download for portable swiftDialog installation if not installed on the system
---- 1.2.0 | 2024-07-24 ----
Renamed validateRunTiming function to validateRunInterval
Added validateRunTiming function to only create non-forced prompts at the beginning or end of a business day, or on weekends
Fixed incorrect timestamp marking in writeRunReceipt
Removed ontop argument from dialog windows
Added "defer" to possible item run results, replacing "none" if an app was deferred
Added additional call to writeRunReceipt if the timer expires or dialog is killed
"""
##########################
##### Import Modules #####
##########################
import argparse
import hashlib
import json
import logging
import platform
import plistlib
import random
import re
import requests
import subprocess
import sys
import time
import urllib.parse
from AppKit import NSWorkspace
from Cocoa import NSRunningApplication
from Quartz.CoreGraphics import CGGetOnlineDisplayList, CGDisplayIsAsleep
from SystemConfiguration import SCDynamicStoreCopyConsoleUser
from datetime import datetime
from pathlib import Path
from shutil import disk_usage
from tempfile import NamedTemporaryFile
##########################
###################################################################################
###################### ---- DO NOT EDIT BELOW THIS LINE ---- ######################
###################################################################################
#### Parse Command Line Arguments and Set Default Values ####
parser = argparse.ArgumentParser(
description="Control the behavior of this script",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
argument_default=argparse.SUPPRESS,
)
parser.add_argument(
"--silent",
action=argparse.BooleanOptionalAction,
help="Determines whether or not the script runs silently or generates a prompt",
)
parser.add_argument(
"--defer",
action=argparse.BooleanOptionalAction,
help="Identifies this run as triggered by a deferral. Useful for testing only, as the run timing will always evaluate as valid.",
)
parser.add_argument(
"--selfservice",
default="",
const="",
nargs="?",
help="Enables an ultralight interface for updating a single app, generally from a Self Service item. Accepts a single argument for the display name of the app to be updated. This name must match the app's associated metadata configuration profile.",
)
parser.add_argument(
"--org",
nargs="?",
help="Name of your org, used to create deferral and data directories",
)
parser.add_argument(
"-d",
"--dialog",
nargs="?",
type=Path,
help="Full path to the dialog binary, if somewhere other than /usr/local/bin/dialog",
)
parser.add_argument(
"-i",
"--icon",
nargs="?",
type=Path,
help="Full path to an icon for your org/team/etc. Placed at the top of the prompt infobox.",
)
parser.add_argument(
"--processes",
nargs="+",
help='Space-separated list of process names to check if running. Determines the status of "Security Systems" in the prompt sidebar.',
)
parser.add_argument(
"-v", "--verbose", action="count", default=0, help="Enable debug logging"
)
parser.add_argument(
"--prefsfile",
default=None,
help="Full path or URL to a json file to set any/all options. Keys are identical to command-line long-form option flags (example: 'path' for a custom dialog binary path). Preferences set on the command line will take precedence over any found in a json file.",
)
parser.add_argument(
"--saveprefs",
action=argparse.BooleanOptionalAction,
default=False,
help='Overwrite an existing (or create new) preferences file at "/Library/Application Support/org/appUpdates/preferences.json". By default, prefs will be created if not present but NOT overwritten. Preferences set via configuration profile take precedence over local or URL-sourced preferences.',
)
parser.add_argument(
"--setsilent",
type=int,
nargs="?",
const=3600,
default=None,
help="Create and load a LaunchDaemon to run this script silently every x seconds. Default: every hour (3600) NOTE: Be sure to set org, icon, etc. or specify a preferences file with this data.",
)
parser.add_argument(
"--setprompt",
type=int,
nargs="?",
const=604800,
default=None,
help="Create and load a LaunchDaemon to run this script in prompt mode every x seconds. Default: every 7 days (604800) NOTE: Be sure to set org, icon, etc. or specify a preferences file with this data.",
)
parser.add_argument(
"--setwatchpath",
action=argparse.BooleanOptionalAction,
default=False,
help="Create and load a LaunchDaemon to trigger a silent run whenever a configuration profile is added, removed, or modified on the device. WARNING: This can potentially trigger a lot of silent runs depending on how much configuration profile traffic you have. This should be relatively inconsequential, but use with caution.",
)
parser.add_argument("--version", action="version", version=f"{scriptVersion}")
args = parser.parse_args()
## Get current interpreter and script paths for use in LaunchDaemons
pythonPath = sys.executable
scriptPath = sys.argv[0]
def endRun(exitCode=None, logLevel="info", message=None):
if str(exitCode).isdigit():
exitCode = int(exitCode)
logCmd = getattr(logging, logLevel, "info")
logCmd(message)
sys.exit(exitCode)
## Check for a preferences configuration profile, URL, or local json file and attempt to parse if found
prefsData = None
profilePath = Path("/Library/Managed Preferences/com.github.swiftpatch.plist")
if profilePath.exists():
print("Found configuration profile for preferences, processing...")
prefsSource = "profile"
try:
prefsData = plistlib.loads(profilePath.read_bytes())
print("Successfully loaded preferences from configuration profile")
except:
endRun(2, "critical", "Failed to fetch preferences from configuration profile")
if prefs := args.prefsfile and not prefsData:
print("Loading preferences from file...")
## Try to get prefs from URL
if prefs.startswith("http://") or prefs.startswith("https://"):
print(f"Found URL for preferences: {prefs}")
try:
prefsData = json.loads(requests.get(prefs).content)
prefsSource = "url"
except:
endRun(2, "critical", "Failed to fetch prefs from URL")
## Try to get prefs from file
elif Path(prefs).exists:
print(f"Attempting to read preferences from {prefs}...")
try:
prefsData = json.loads(Path(prefs).read_text())
prefsSource = "file"
except:
endRun(2, "critical", "Failed to fetch prefs from file")
else:
endRun(2, "critical", "Prefs file was specified but unable to be loaded")
print(f"Successfully retrieved preferences from {prefsSource}")
silentRun = args.silent if "silent" in args else None
deferredRun = args.defer if "defer" in args else None
selfService = args.selfservice if "selfservice" in args else None
orgName = args.org if "org" in args else None
dialogPath = Path(args.dialog) if "dialog" in args else None
iconPath = Path(args.icon) if "icon" in args else None
requiredProcessList = args.processes if "process" in args else None
verbose = args.verbose if "verbose" in args else None
## If preferences data was gathered from a file or URL, set those values
## Values from a preferences file are only set if the same option was not specified as a command-line argument
## For example, if a preferences file sets silent = False but the --silent option is specified on the command-line, the script will proceed as a silent run
if prefsData:
silentRun = silentRun or prefsData.get("silent", "false").lower() == "true"
deferredRun = deferredRun or prefsData.get("defer", "false").lower() == "true"
orgName = orgName or prefsData.get("org", "org")
dialogPath = dialogPath or Path(prefsData.get("dialog", "/usr/local/bin/dialog"))
iconPath = iconPath or Path(
prefsData.get("icon"), "/System/Library/CoreServices/Installer.app"
)
requiredProcessList = requiredProcessList or prefsData.get("processes", [])
verbose = verbose or prefsData.get("verbose", 0)
## If an update is being run from Self Service, an ultralight interface will be used
selfService = selfService or prefsData.get("selfservice", "").lower()
## Set additional preferences gathered from a file, if provided
## Simple mode defaults to False, using the full dialog with sidebar and instructions
## Speedtest (for estimating download time) defaults to True unless simple mode is enabled, in which case the data is not needed and the time can be saved
simpleMode = str(prefsData.get("simple", "false")).lower() == "true"
speedtest = all(
[not simpleMode, str(prefsData.get("speedtest", "false")).lower() == "true"]
)
## The dialog can be placed in any of validPositions on the user's screen
## Defaults to center if not set or set value is invalid
validPositions = [
"topleft",
"left",
"bottomleft",
"top",
"center",
"bottom",
"topright",
"right",
"bottomright",
]
position = (
prefsData.get("position").lower()
if "position" in prefsData.keys()
and prefsData.get("position").lower() in validPositions
else "center"
)
else:
prefsSource = "cli"
silentRun = args.silent
deferredRun = args.defer
selfService = args.selfservice
orgName = args.org
dialogPath = Path(args.dialog)
iconPath = Path(args.icon)
requiredProcessList = args.processes
verbose = args.verbose
###############################
#### Logging configuration ####
###############################
## Current date
dateToday = datetime.date(datetime.now())
## Local log file
logDir = Path(f"/Library/Application Support/{orgName}/Logs/{dateToday}")
logDir.mkdir(parents=True, exist_ok=True)
logFile = logDir.joinpath(
f"appUpdate-log_{time.asctime()}_silent-{silentRun}_defer-{deferredRun}.log"
)
## Configure root logger
logger = logging.getLogger()
logger.handlers = []
## Create handlers
logToFile = logging.FileHandler(str(logFile))
logToConsole = logging.StreamHandler(sys.stdout)
## Configure logging level and format
if verbose:
logLevel = logging.DEBUG
logFormat = logging.Formatter(
"[%(asctime)s %(filename)s->%(funcName)s():%(lineno)s]%(levelname)s: %(message)s"
)
else:
logLevel = logging.INFO
logFormat = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
## Set root and handler logging levels
logger.setLevel(logLevel)
logToFile.setLevel(logLevel)
logToConsole.setLevel(logLevel)
## Set log format
logToFile.setFormatter(logFormat)
logToConsole.setFormatter(logFormat)
## Add handlers to root logger
logger.addHandler(logToFile)
logger.addHandler(logToConsole)
###############################
## Log the script version
logging.info(f"swiftPatch version {scriptVersion}")
## Generate a randomized temporary file to use for dialog commands
dialogCommandFile = Path(
NamedTemporaryFile(prefix="dialogCommand-", suffix=".log").name
)
logging.debug(f"Using dialog command file at {str(dialogCommandFile)}")
## Log current date
logging.debug(f"Today is {dateToday}")
## Current user username and UID
userName, userUID, _ = SCDynamicStoreCopyConsoleUser(None, None, None)
logging.debug(f"Current user is {userName} with UID {userUID}")
## Define an empty reason for forcing an update by default
forceReason = None
## Path for storing deferral data
deferPath = Path(f"/Library/Application Support/{orgName}/appUpdates")
deferPath.mkdir(parents=True, exist_ok=True)
deferFile = deferPath.joinpath("deferralData.json")
logging.debug(f"Deferral data will be stored at {str(deferFile)}")
## Receipt file for last run
runReceipt = Path(f"/Library/Application Support/{orgName}/appUpdates/lastRun.json")
logging.debug(f"Last run data will be stored at {str(runReceipt)}")
## Preferences file to save settings between runs
prefsPath = Path(f"/Library/Application Support/{orgName}/appUpdates/preferences.json")
## Byte codes for emoji to be used in prompts
## Great place to find these: https://apps.timwhitlock.info/emoji/tables/unicode
emojiDict = {
"greenHeart": b"\xf0\x9f\x92\x9a",
"infoBox": b"\xE2\x84\xB9",
"lightbulb": b"\xF0\x9F\x92\xA1",
"magnifyingGlass": b"\xF0\x9F\x94\x8D",
"warningSymbol": b"\xE2\x9A\xA0",
"hourglass": b"\xE2\x8F\xB3",
"greenCheck": b"\xE2\x9C\x85",
"redX": b"\xE2\x9D\x8C",
"greenX": b"\xE2\x9D\x8E",
"thanksHands": b"\xF0\x9F\x99\x8F",
"eyes": b"\xF0\x9F\x91\x80",
"restartIcon": b"\xF0\x9F\x94\x81",
"rightArrow": b"\xE2\x9E\xA1",
"speechBalloon": b"\xF0\x9F\x92\xAC",
}
## Initialize some dicts and vars for dialog lists and behaviors
appListEntries = {}
dialogProgressList = {"listItem": []}
dialogPromptList = {"checkbox": []}
forcePrompt = False
##########################################################
###############################
##### Verify Requirements #####
###############################
## If the required swiftDialog version is not installed,
## or swiftDialog is not installed at all,
## download a portable copy to use for this run
def downloadDialog():
portableDialogUrl = "https://github.com/swiftDialog/swiftDialog/releases/download/v2.5.0/swiftDialog.dmg"
portableDialogHash = (
"6cf5e7202dfc07dcfe6a198d0815146738da5fe3c54217978ad383721ec0ea71"
)
logging.warning(
"A permanent swiftDialog installation was not found or not up to date, downloading a portable build for this run..."
)
dialogDownloadPath = Path("/private/tmp/dialog.pkg")
with open(dialogDownloadPath, "wb") as pkg:
pkg.write(requests.get(portableDialogUrl, allow_redirects=True).content)
with open(dialogDownloadPath, "rb") as pkg:
pkgHash = hashlib.file_digest(pkg, "sha256").hexdigest()
if portableDialogHash == pkgHash:
installerCmd = [
"/usr/sbin/installer",
"-pkg",
str(dialogDownloadPath),
"-target",
"/",
]
def requirementsCheck():
requirementsMet = True
## Dialog present and meets minimum version
if Path.is_file(dialogPath):
dialogVersion = subprocess.run(
[dialogPath, "-v"], capture_output=True, text=True
).stdout.strip()
logging.debug(f"Using dialog version {dialogVersion} at {dialogPath}")
if not re.match(requiredDialogPattern, dialogVersion):
logging.warning(
f"Dialog version {dialogVersion} does not meet minimum {requiredDialogVersionString}!"
)
downloadDialog()
else:
logging.warning(
"Dialog binary was not found. Check your installation and/or specified path!"
)
downloadDialog()
## Jamf binary present and server available
if Path("/usr/local/bin/jamf").exists():
logging.debug("jamf binary found")
cmd = ["/usr/local/bin/jamf", "checkJSSConnection", "-retry", "5"]
try:
subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True)
jamfPrefs = loadPlist(
Path("/Library/Preferences/com.jamfsoftware.jamf.plist")
)
logging.debug(f'jamf server at {jamfPrefs.get("jss_url")} is available')
except subprocess.CalledProcessError:
logging.critical("jamf server could not be reached!")
requirementsMet = False
else:
logging.critical("jamf binary not found!")
requirementsMet = False
## Python executable meets minimum version
pythonVersionCmd = [pythonPath, "-V"]
cmdResult = subprocess.run(pythonVersionCmd, text=True, capture_output=True)
pythonVersion = cmdResult.stdout.split(" ")[-1].strip()
if not re.match(requiredPythonPattern, pythonVersion):
logging.critical(
f"Python version {pythonVersion} does not meet minimum {requiredPythonVersionString}!"
)
requirementsMet = False
else:
logging.debug(f"Using Python version {pythonVersion} at {pythonPath}")
if getProcessStatus("dialog"):
logging.info("Another dialog is currently running!")
requirementsMet = False
else:
logging.debug("No dialog processes found, continuing...")
return requirementsMet
##########################
#### Define Functions ####
##########################
## Load and return data from a plist
def loadPlist(plistPath):
logging.debug(f"Loading plist data from {str(plistPath)}")
plistData = plistlib.loads(plistPath.read_bytes())
return plistData
## Dump data into a plist
def dumpPlist(plistData, plistPath):
logging.debug(f"Dumping plist data to {str(plistPath)}")
logging.debug(f"Plist data sent: {plistData}")
plistPath.write_bytes(plistlib.dumps(plistData))
## Load and return data from a json file
def loadJson(jsonPath):
logging.debug(f"Loading json data from {str(jsonPath)}")
jsonData = json.loads(jsonPath.read_text())
return jsonData
## Dump data into a json file
def dumpJson(jsonData, jsonPath):
logging.debug(f"Dumping json data to {str(jsonPath)}")
logging.debug(f"json data sent: {jsonData}")
jsonPath.write_text(json.dumps(jsonData))
## Attempt to load a LaunchDaemon
def loadLaunchDaemon(daemonPath):
## launchctl command to load the LaunchDaemon once created
cmd = [
"/bin/launchctl",
"load",
"-w",
str(daemonPath),
]
## Try to unload the LaunchDaemon so new data can be picked up if this is an update, and fail silently otherwise
try:
subprocess.run(["/bin/launchctl", "unload", str(daemonPath)], text=True)
logging.info("Unloaded existing LaunchDaemon")
except:
logging.debug(
"Failed to unload existing LaunchDaemon. This message is not an error and can be ignored."
)
## Load the LaunchDaemon
try:
subprocess.run(cmd, text=True, check=True)
logging.info("Daemon created and loaded successfully")
return 0
except subprocess.CalledProcessError:
logging.error(
f"Something went wrong loading the LaunchDaemon: {subprocess.CalledProcessError.output}"
)
return subprocess.CalledProcessError.returncode
## Return a requested emoji
def getEmoji(emojiLabel):
try:
emoji = emojiDict[emojiLabel].decode("utf-8")
logging.debug(f"Retrieved emoji: {emojiLabel} ({emoji})")
except KeyError:
emoji = None
logging.warning(f"Failed to retrieve emoji: {emojiLabel}")
return emoji
## Send an update to the dialog command log
def updateDialog(command):
if not command:
logging.warning("No command specified to updateDialog, returning...")
return
with open(dialogCommandFile, "a") as commandLog:
logging.debug(f"Appending command '{command}' to {dialogCommandFile}")
commandLog.write(f"{command}\n")
## Send a status update to the dialog command file
def updateStatus(status, statustext, listIndex):
if type(listIndex) is not int or listIndex > len(appListEntries):
logging.error(
"Invalid data sent to updateStatus function. Dialog command file will not be updated."
)
return
if silentRun and not forcePrompt:
return
if not selfService:
itemStatus = f"status: {status}" if "progress" not in status else status
dialogCommand = (
f"listitem: index: {listIndex}, {itemStatus}, statustext: {statustext}"
)
progressUpdate = {"status": itemStatus, "statustext": statustext}
dialogProgressList["listItem"][listIndex].update(progressUpdate)
updateDialog(dialogCommand)
else:
if "progress" in status:
updateDialog(status)
elif status == "wait":
updateDialog("progress: reset")
updateDialog(f"progresstext: {statustext}")
## Check provided array of processes for security apps or other requirements
def getProcessStatus(processName):
logging.debug(f"Checking for running process {processName}...")
checkCmdStatus = not bool(
subprocess.run(
["/usr/bin/pgrep", processName],
text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
).returncode
)
logging.info(f"Process status check for {processName}: {checkCmdStatus}")
return checkCmdStatus
## Get the required version of macOS from a Nudge configuration profile (if present), or the latest version from the Apple catalog
## Then, compare it to the currently-installed version and return a boolean based on whether or not the versions match (i.e. the device is up to date)
def getOSVersionData():
macOSInstalledVer = platform.platform().split("-")[1]
logging.info(f"Installed macOS version is {macOSInstalledVer}")
logging.debug("Getting latest macOS version")
nudgeConfigFile = Path(
"/Library/Managed Preferences/com.github.macadmins.Nudge.plist"
)
if nudgeConfigFile.exists():
logging.debug("Found Nudge configuration profile")
try:
nudgeConfigData = loadPlist(
Path("/Library/Managed Preferences/com.github.macadmins.Nudge.plist")
)
latestVersion = nudgeConfigData["osVersionRequirements"][0][
"requiredMinimumOSVersion"
]
logging.info(
f"Required minimum macOS version from Nudge is {latestVersion}"
)
except:
logging.error(
"Unable to load required macOS version from Nudge configuration profile"
)
else:
logging.debug("Fetching latest macOS version from SOFA feed")
catalogURL = "https://sofa.macadmins.io/v1/macos_data_feed.json"
catalogData = requests.get(catalogURL)
try:
catalog = json.loads(catalogData.text)
catalogUpdatedDate = catalogData.headers.get("Last-Modified")
logging.info(
f"Successfully retrieved macOS data from SOFA feed, last updated {catalogUpdatedDate}"
)
except:
logging.error("Something went wrong loading Apple catalog data")
upToDate = False
latestVersion = (
sorted(
catalog.get("OSVersions"),
key=lambda x: x.get("Latest").get("ProductVersion"),
reverse=True,
)[0]
.get("Latest")
.get("ProductVersion")
)
if not re.match(r"^\d{2}\.\d{1,2}(?>\.\d{1,2})?$", latestVersion):
logging.warning(
"Latest version from Apple catalog does not match expected semantic versioning pattern!"
)
upToDate = False
logging.info(f"Latest macOS version from SOFA is {latestVersion}")
## If version strings are equal OR the installed major version is greater than the latest required/available major version, consider macOS up to date
if macOSInstalledVer == latestVersion or int(macOSInstalledVer.split(".")[0]) > int(
latestVersion.split(".")[0]
):
logging.info("macOS is up to date")
upToDate = True
else:
logging.info("macOS is not up to date")
upToDate = False
return upToDate, macOSInstalledVer
## Get device health info for dialog display
def getDeviceHealth():
logging.info("Collecting device health data...")
macOSUpdated, macOSInstalledVer = getOSVersionData()
deviceInfo = {
"macOSVersion": macOSInstalledVer,
"macOSIsCurrent": macOSUpdated,
"serialNumber": platform.node().split("-")[1],
"systemUptimeDays": round(time.clock_gettime(time.CLOCK_MONOTONIC_RAW) / 86400),
"healthCheck": (
True
if (
len(requiredProcessList) > 0
and all(getProcessStatus(x) for x in requiredProcessList)
)
or len(requiredProcessList) == 0
else False
),
"diskUsage": int((disk_usage("/").used / disk_usage("/").total) * 100),
}
infoboxData = f"\
### Device Information\n\
**Serial Number**\n\n{deviceInfo['serialNumber']}\n\n\
**macOS Version**\n\n{getEmoji('greenCheck') if deviceInfo['macOSIsCurrent'] else getEmoji('redX')} {deviceInfo['macOSVersion']}{'<br>***Please update!***' if not deviceInfo['macOSIsCurrent'] else ''}\n\n\
**Last Restart**\n\n{getEmoji('greenCheck') if deviceInfo['systemUptimeDays'] < 14 else getEmoji('redX')} {deviceInfo['systemUptimeDays']} day{'s' if deviceInfo['systemUptimeDays'] != 1 else ''} ago{'<br>***Please restart soon!***' if deviceInfo['systemUptimeDays'] >= 14 else ''}\n\n\
**Disk Usage**\n\n{getEmoji('greenCheck') if deviceInfo['diskUsage'] < 75 else getEmoji('warningSymbol')} {deviceInfo['diskUsage']}%\n{'All good!' if deviceInfo['diskUsage'] < 75 else '<br>***Getting full!***'}\n\n\
**Security Systems**\n\n{getEmoji('greenCheck') + ' All good!' if deviceInfo['healthCheck'] else getEmoji('redX') + '<br>***Please contact support!***'}\n\n\
_A message from {orgName} IT_ {getEmoji('greenHeart')}"
logging.debug(f"Collected device information for dialog sidebar: {deviceInfo}")
return infoboxData
## Read the app's creation date and compare it to the current date
## If more than 60 days have passed since the last update, force the update now
def checkInstallDate(bid, versionKey):
appPath = getAppPath(bid, versionKey)
if not appPath:
return True
appLastInstalled = datetime.fromtimestamp(appPath.stat().st_birthtime).date()
installDelta = dateToday - appLastInstalled
logging.info(
f"{str(appPath.stem)} was last updated on {appLastInstalled}, which was {installDelta.days} days ago."
)
if installDelta.days > 60:
logging.warning(
"More than 60 days have passed since the last update, forcing update now..."
)
global forceReason
forceReason = "Last update >60 days ago"
return False
return True
## Make sure we're not running too frequently or too soon after the last run
def validateRunInterval():
logging.info("Validating run interval...")
## If running from a deferral LaunchDaemon, the run is valid and no checks need to be done
if deferredRun:
logging.info("Run is valid: deferred execution")
return True
timeNow = int(time.time())
logging.debug(f"Current epoch time is {timeNow}")
if runReceipt.exists():
receiptData = json.loads(runReceipt.read_text())
lastRunTime = (
receiptData.get("silent" if silentRun else "prompt").get("runTime") or 0
)
runDelta = timeNow - lastRunTime
logging.debug(f"Last run time was {lastRunTime}")
logging.debug(f"Update script was last run {runDelta} seconds ago")
else:
logging.info("Run is valid: no run receipt found")
return True
## Make sure this is running at least once every other week minimum
if runDelta >= 1209600:
logging.warning(
"More than two weeks have passed since the last run, forcing run now..."
)
return True
## If run less than 30 minutes ago, or prompted less than 8 hours ago and trying to prompt again (not counting user deferrals), stop
if runDelta <= 1800 or (not silentRun and runDelta <= 28800):
logging.warning("Run attempted too soon after last run, skipping...")
return False
else:
logging.info("Run is valid: acceptable interval since last run")
return True
## Only prompt at the beginning or end of the user's (extended) workday (7-10a / 3-8p local time)
def validateRunTiming(runIntervalValid):
if not runIntervalValid:
logging.debug("Run interval check failed, skipping run timing check")
return False
logging.info("Validating run timing...")
currentHour = int(datetime.strftime(datetime.today(), "%H"))
currentDay = int(datetime.strftime(datetime.today(), "%w"))
logging.debug(f"Current hour is {currentHour} on weekday {currentDay}")