xolo-admin 1.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/data/client/xolo ADDED
@@ -0,0 +1,1233 @@
1
+ #!/bin/zsh
2
+
3
+
4
+ # Copyright 2025 Pixar
5
+ #
6
+ # Licensed under the terms set forth in the LICENSE.txt file available at
7
+ # at the root of this project.
8
+
9
+ # SHELL SETUP
10
+ ###############################
11
+ ###############################
12
+
13
+ # Load the zsh/zutil module for several utility functions
14
+ zmodload zsh/zutil
15
+
16
+ # for perl-style regexps
17
+ #
18
+ # DISABLED FOR NOW - macOS before 15 Sequoia doesn't have the pcre module,
19
+ # and we want to support 14 Sonoma for now.
20
+ # The file required is /usr/lib/zsh/5.9/zsh/pcre.so
21
+ #
22
+ # Until then, all our regexps now use posix style
23
+ #
24
+ # setopt RE_MATCH_PCRE
25
+
26
+ # ENVIRONMENT
27
+ ###############################
28
+ ###############################
29
+
30
+ # Needed for the UTF-8 chars in the lsreg output
31
+ export LANG="en_US.UTF-8"
32
+ export LC_ALL="en_US.UTF-8"
33
+
34
+ # CONSTANTS
35
+ ###############################
36
+ ###############################
37
+
38
+ # The version of xolo
39
+ # This should be updated automatically when this script is packaged
40
+ XOLO_VERSION="0.0.0"
41
+
42
+ # The usage message for xolo
43
+ USAGE='xolo [options] <command> [ title1[=version1] [ title2[=version2] ... ]]'
44
+
45
+ # The URL for more information about xolo
46
+ XOLO_DOX_URL='https://pixaranimationstudios.github.io/xolo-home/'
47
+
48
+ # The long path to the lsregister command
49
+ LSREG='/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister'
50
+
51
+ # The path to the jamf binary
52
+ JAMF='/usr/local/bin/jamf'
53
+
54
+ # The path to the client data json file
55
+ CLIENT_DATA_JSON_FILE="/Library/Application Support/xolo/client-data.json"
56
+
57
+ # The jamf policy trigger to update the client data
58
+ UPDATE_CLIENT_DATA_TRIGGER='update-xolo-client-data'
59
+
60
+ # In the CLI args, titles and versions are separated by the first
61
+ # occurrence of the equals sign
62
+ TITLE_VERSION_SEPARATOR='='
63
+
64
+
65
+ # These commands must be run as root
66
+ ROOT_COMMANDS=(
67
+ install
68
+ i
69
+ uninstall
70
+ u
71
+ update
72
+ U
73
+ refresh
74
+ r
75
+ expire
76
+ e
77
+ )
78
+
79
+ # GLOBAL VARIABLES
80
+ ###############################
81
+ ###############################
82
+
83
+ # From the command line
84
+
85
+ # options
86
+ show_help=
87
+ be_verbose=
88
+ debugging_on=
89
+ no_versions=
90
+ show_xolo_version=
91
+
92
+ # arguments
93
+ command=
94
+ targets=
95
+ title=
96
+ version=
97
+
98
+ # An Associative Array to hold the installed apps
99
+ # is populated by get_installed_apps()
100
+ typeset -A installed_apps
101
+
102
+ # FUNCTIONS
103
+ ###############################
104
+ ###############################
105
+
106
+ # Print a message to stderr
107
+ ###############################
108
+ function echoerr() { echo "$@" 1>&2; }
109
+
110
+ # Die with an error message and status
111
+ ###############################
112
+ function die() {
113
+ local msg="$1"
114
+ local mystat=1
115
+
116
+ [[ -n "$2" ]] && mystat=$2
117
+ echoerr "ERROR: $msg"
118
+ exit $mystat
119
+ }
120
+
121
+ # Die if not root and the command being run is in the list of root commands
122
+ #################################
123
+ function must_be_root() {
124
+ if (( $ROOT_COMMANDS[(Ie)$command] )); then
125
+ [[ $EUID -ne 0 ]] && die "You must be root to use the '$command' command."
126
+ fi
127
+ return 0
128
+ }
129
+
130
+ # Show the help message
131
+ ###############################
132
+ function show_help() {
133
+ cat <<ENDHELP
134
+ xolo: manage software titles on this computer
135
+
136
+ Usage:
137
+ $USAGE
138
+
139
+ Options:
140
+ -h, -H, --help: Show this help message.
141
+ -v, --verbose: Enable verbose mode, extra information will be printed.
142
+ -d, --debug: Enable debug mode, extra debug information will be printed.
143
+ Implies --verbose.
144
+ -r, --recon: With 'install' or 'uninstall' run a 'jamf recon' after the
145
+ operation completes successfully.
146
+ -n, --no-versions: With the 'list-titles' and 'list-installed' commands,
147
+ do not show the versions available, or the version
148
+ installed, respectively.
149
+ -V, --version: Show the version of xolo.
150
+
151
+ Commands:
152
+ install, i <title>[=<version>] [<title2>[=<version2>] ...]
153
+ Install a title, or specific version thereof (e.g. a version currently in pilot)
154
+ If no version is specified, the currently released version will be installed.
155
+
156
+ uninstall, u <title> [<title2> ...]
157
+ Uninstall a title, if possible. Not all titles are uninstallable via xolo.
158
+
159
+ update, U
160
+ Run any pending updates to currently installed titles, or install new titles
161
+   that are scoped to auto-install on this computer. The exact behavior depends on
162
+    what's installed and the title's configuration. This also happens automatically
163
+    every time this computer checks in with Jamf Pro.
164
+ Note: this just runs the 'jamf policy' command, which will execute any pending
165
+ Jamf policies triggered by "recurring check-in".
166
+
167
+ refresh, r
168
+ Update xolo's information about current titles and versions.
169
+ This also happens automatically at least daily, when this computer is online.
170
+
171
+ list-titles, lt
172
+ List all known titles and versions. Not all may be available for install,
173
+ e.g., if this computer is in an excluded computer-group
174
+
175
+ list-installed, li
176
+ List titles installed on this computer, that are known to xolo. This may take
177
+ a few moments, since all titles with version scripts will run those scripts
178
+ to see if they are installed.
179
+
180
+ details, d <title>[=<version>] [<title2>[=<version2>] ...]
181
+ Show detailed information about titles, or a specific versions thereof.
182
+ Note that titles and versions contain different sets of information, so the output
183
+ will differ depending on what you specify.
184
+
185
+ expire, e
186
+ Expire the given title if it has not been used in its defined expiration period.
187
+ Does nothing if the title is not expirable.
188
+ This also happens automatically at least daily, when this computer is online.
189
+
190
+ help, h
191
+ Show this help message. The same as -h or --help.
192
+
193
+ Examples:
194
+ xolo install transmogrifier
195
+ Installs the currently released version of the title "transmogrifier".
196
+
197
+ xolo install laserbeam transmogrifier=2.0
198
+ Installs the currently released version of the title "laserbeam" and version 2.0 of
199
+ the title "transmogrifier", if it is in pilot or already released.
200
+
201
+ xolo uninstall transmogrifier laserbeam
202
+ Uninstalls the titles "transmogrifier" and "laserbeam, if they are uninstallable via
203
+ xolo and currently installed. Note that versions are ignored for uninstalling, since
204
+ only one version of a title can be installed at a time.
205
+
206
+ xolo list-titles
207
+ Lists all titles known to xolo, with their versions and statuses.
208
+
209
+ xolo details laserbeam transmogrifier=2.0
210
+ Shows detailed information about the title "laserbeam" and version 2.0 of the title
211
+ "transmogrifier".
212
+
213
+ For more information about xolo, see $XOLO_DOX_URL
214
+ ENDHELP
215
+ } # end show_help
216
+
217
+ # Parse the command line arguments
218
+ ###############################
219
+ function parse_cli() {
220
+ zparseopts -D -F -E -- \
221
+ {h,H,-help}=show_help \
222
+ {v,-verbose}=be_verbose \
223
+ {d,-debug}=debugging_on \
224
+ {n,-no-versions}=no_versions \
225
+ {r,-recon}=do_recon \
226
+ {V,-version}=show_xolo_version || \
227
+ die 'Unknonwn Options or Args.'
228
+
229
+ # they are in arrays, but we want regular vars
230
+ show_help=$show_help[-1]
231
+ be_verbose=$be_verbose[-1]
232
+ debugging_on=$debugging_on[-1]
233
+ no_versions=$no_versions[-1]
234
+ do_recon=$do_recon[-1]
235
+ show_xolo_version=$show_xolo_version[-1]
236
+
237
+ # if debugging is on, verbose is also on
238
+ [[ -n $debugging_on ]] && be_verbose=1
239
+
240
+ command=$1
241
+ [[ ${#@} -gt 0 ]] && shift
242
+
243
+ targets=("${(@)@}")
244
+
245
+ debug "Parsed command line:"
246
+ debug "..show_help is: $show_help"
247
+ debug "..be_verbose is: $be_verbose"
248
+ debug "..debugging_on is: $debugging_on"
249
+ debug "..no_versions is: $no_versions"
250
+ debug "..show_xolo_version is: $show_xolo_version"
251
+
252
+ debug "..command is: $command"
253
+ debug "..targets are: $targets"
254
+ }
255
+
256
+ # Parse a title and version from the command line, where they may be given in the form
257
+ # title or title=version
258
+ #################################
259
+ function parse_title_and_version() {
260
+ local arg=$1
261
+ debug "Parsing title and version from arg: '$arg'"
262
+
263
+ if [[ "$arg" == *"$TITLE_VERSION_SEPARATOR"* ]] ; then
264
+ # everything before the first = is title
265
+ title=${arg%%$TITLE_VERSION_SEPARATOR*}
266
+ # everything after the first = is version
267
+ version=${arg#*$TITLE_VERSION_SEPARATOR}
268
+ else
269
+ title="$arg"
270
+ version=''
271
+ fi
272
+
273
+ debug "..title is: '$title'"
274
+ debug "..version is: '$version'"
275
+
276
+ return 0
277
+ }
278
+
279
+ # quick test for debugging_on
280
+ ###############################
281
+ function debugging_on() {
282
+ [[ -n "$debugging_on" ]]
283
+ }
284
+
285
+ # quick test for be_verbose
286
+ ###############################
287
+ function be_verbose() {
288
+ [[ -n "$be_verbose" ]]
289
+ }
290
+
291
+ # Print a message to stout
292
+ ###############################
293
+ function say() {
294
+ echo "$*"
295
+ }
296
+
297
+ # Print a message to stdout if we're in verbose or debug mode
298
+ ###############################
299
+ function verbose() {
300
+ be_verbose && echo "$*"
301
+ }
302
+
303
+ # Print a message to stderr if we're in debug mode
304
+ ###############################
305
+ function debug() {
306
+ debugging_on && echo "DEBUG: $*" 1>&2
307
+ }
308
+
309
+ # Gather all installed bundle ids and installed versions from lsreg
310
+ # store them in an associative array in $installed_apps.
311
+ # keys are bundle ids, values are app name & installed version
312
+ # separated by a semicolon
313
+ # e.g. 'com.apple.Safari => Safari.app;14.0.3'
314
+ ###############################
315
+ function get_installed_apps() {
316
+ debug "Getting all installed .apps on this Mac..."
317
+ local app_name
318
+ local app_bundle_id
319
+ local app_vers
320
+
321
+ # loop thru lsreg output
322
+ "$LSREG" -dump Bundle | while IFS= read -r line ; do
323
+ # a line if ------ starts a new record
324
+ if [[ "$line" =~ '^-+$' ]] ; then
325
+ unset app_name
326
+ unset app_bundle_id
327
+ unset app_vers
328
+ continue
329
+ fi
330
+
331
+ # $MATCH is the whole matched string, $match is the array of captures
332
+ # NOTE this only works if the path is listed before the CFBundleIdentifier
333
+ # which seems to be the case in the lsreg output
334
+ [[ "$line" =~ '^path:[[:space:]]+/.*/(.+\.app) \(' ]] && app_name=$match[1]
335
+ [[ "$line" =~ '^[[:space:]]+CFBundleIdentifier = \"(.+)\";' ]] && app_bundle_id=$match[1]
336
+ [[ "$line" =~ '^[[:space:]]+CFBundleShortVersionString = \"(.+)\";' ]] && app_vers=$match[1]
337
+
338
+ if [[ -n "$app_name" && -n "$app_bundle_id" && -n "$app_vers" ]] ; then
339
+ installed_apps[$app_bundle_id]="$app_name;$app_vers"
340
+ unset app_name
341
+ unset app_bundle_id
342
+ unset app_vers
343
+ fi
344
+ done
345
+ # installed_apps is now an associative array with bundle ids as keys and app names as values
346
+
347
+ # print the installed apps - this is a long list
348
+ if debugging_on ; then
349
+ debug "All installed .apps on this Mac:"
350
+ for bundle app in ${(kv)installed_apps}; do
351
+ debug "'$bundle' -> '$app'"
352
+ done
353
+ fi
354
+
355
+ } # end installed_apps
356
+
357
+ # Extract data from the client-data.json file
358
+ #
359
+ # TODO: Once we only support macOS 15 (Sequoia) and higher, we
360
+ # can use /usr/bin/jq to parse the JSON file instead of using JXA.
361
+ #
362
+ # $1 = a string of javascript that extracts your desired info from
363
+ # the client-data.json file, which has already been parsed into
364
+ # the variable 'parsed_data'
365
+ #
366
+ # your code should process parsed_data however it needs, and then
367
+ # put the desired output string into the 'result' var, which has already
368
+ # been declared.
369
+ #
370
+ # The result of your processing will be sent to stdout of this function
371
+ #######################################
372
+ function extract_json_data() {
373
+ debug "Extracting data from client-data.json..."
374
+ local processing_code="$1"
375
+ local CLIENT_DATA_PROCESSING_JS
376
+
377
+ read -r -d '' CLIENT_DATA_PROCESSING_JS <<ENDJAVASCRIPT
378
+ // this gives us access to the local file system
379
+ var app = Application.currentApplication();
380
+ app.includeStandardAdditions = true;
381
+
382
+ // run() is automatically executed when the program is called, and will print any output returned.
383
+ function run() {
384
+ var parsed_data = JSON.parse(app.read("${CLIENT_DATA_JSON_FILE}"));
385
+ var result;
386
+
387
+ // the line below will sub-in whatever code we were passed
388
+ // to process the parsed data and store the result in 'result'
389
+
390
+ ${processing_code}
391
+
392
+ return result
393
+ }
394
+ ENDJAVASCRIPT
395
+
396
+ # in debug mode, show the javascript that will be run
397
+ debug '============= EXECUTING JAVASCRIPT ==========='
398
+ debug "${CLIENT_DATA_PROCESSING_JS}"
399
+ debug '========================'
400
+
401
+ # run the javascript to send the result to stdout, which is the default
402
+ # behavior of of the 'run' function in JXA
403
+ osascript -l "JavaScript" <<< "${CLIENT_DATA_PROCESSING_JS}"
404
+
405
+ }
406
+
407
+ # Outputs all known titles, one per line
408
+ #######################################
409
+ function all_xolo_titles() {
410
+ debug "Getting all known titles..."
411
+ local jscode
412
+
413
+ read -r -d '' jscode <<ENDJAVASCRIPT
414
+ result = Object.keys(parsed_data.titles).join('\n');
415
+ ENDJAVASCRIPT
416
+ extract_json_data "$jscode" | sort
417
+ }
418
+
419
+ # Outputs all known titles, one per line, with versions and statuses
420
+ #######################################
421
+ function all_xolo_titles_with_versions() {
422
+ debug "Getting all known titles with versions and statuses..."
423
+ local jscode
424
+
425
+ read -r -d '' jscode <<ENDJAVASCRIPT
426
+
427
+ result = '';
428
+
429
+ Object.entries(parsed_data.titles).forEach(([title, title_data]) => {
430
+ result += \`\${title}: \`;
431
+
432
+ if (title_data.versions.length > 0) {
433
+ vers_array = title_data.versions.map(
434
+ function (version) {
435
+ return \`\${version.version} (\${version.status})\`;
436
+ } // end function
437
+ ); // end map
438
+
439
+ result += \`\${vers_array.join(', ')}\`;
440
+ } // end if
441
+
442
+ result += "\n";
443
+ }); // end forEach
444
+
445
+ // remove the last newline
446
+ result = result.slice(0, -1);
447
+
448
+ ENDJAVASCRIPT
449
+ extract_json_data "$jscode" | sort
450
+ }
451
+
452
+
453
+ # $1 = the name of a title
454
+ # Outputs all the versions for the title, one per line
455
+ #######################################
456
+ function versions_for_title() {
457
+ debug "Getting versions for title $1..."
458
+ local desired_title=$1
459
+ local jscode
460
+
461
+ read -r -d '' jscode <<ENDJAVASCRIPT
462
+ if (parsed_data.titles["${desired_title}"]) {
463
+ result = parsed_data.titles["${desired_title}"]["version_order"].join('\n');
464
+ } else {
465
+ result = \`Unknown title \${desired_title}\n\`;
466
+ }
467
+ ENDJAVASCRIPT
468
+ extract_json_data "$jscode"
469
+ }
470
+
471
+ # $1 = the name of a title
472
+ # Output the text to be displayed for the 'details' command for a title
473
+ #######################################
474
+ function details_for_title() {
475
+ debug "Getting details for title $1..."
476
+ local desired_title=$1
477
+ local jscode
478
+
479
+ read -r -d '' jscode <<ENDJAVASCRIPT
480
+ if (parsed_data.titles["${desired_title}"]) {
481
+ var title_data = parsed_data.titles["${desired_title}"];
482
+
483
+ result = \`Details of title \${title_data.title}\n\`;
484
+ result += \`..Display Name: \${title_data.display_name}\n\`;
485
+ result += \`..Description: \${title_data.description}\n\`;
486
+ result += \`..Publisher: \${title_data.publisher}\n\`;
487
+ result += \`..Release Groups: \${title_data.release_groups}\n\`;
488
+ result += \`..Excluded Groups: \${title_data.excluded_groups}\n\`;
489
+
490
+ if (title_data.uninstall_script || title_data.uninstall_ids.length > 0) {
491
+ var uninstallable = 'true';
492
+ } else {
493
+ var uninstallable = 'false';
494
+ }
495
+ result += \`..Uninstallable: \${uninstallable}\n\`;
496
+
497
+ if ( uninstallable == 'true' && title_data.expiration && title_data.expire_paths.length > 0) {
498
+ result += \`..Expires after days of disuse: \${title_data.expiration}\n\`;
499
+ result += \`..Expire Paths: \${title_data.expire_paths}\n\`;
500
+ }
501
+
502
+ if ( title_data.self_service ) {
503
+ result += \`..In Self Service: true\n\`;
504
+ result += \`..Self Service Category: \${title_data.self_service_category}\n\`;
505
+ }
506
+
507
+ result += \`..Contact: \${title_data.contact_email}\n\`;
508
+ result += \`..Added to Xolo: \${title_data.creation_date} by \${title_data.created_by}\n\`;
509
+
510
+ result += \`..Versions:\n\`;
511
+ if (title_data.versions.length > 0) {
512
+ title_data.versions.forEach(
513
+ function (arrayItem) {
514
+ result += \`....\${arrayItem.version}: \${arrayItem.status}\n\`;
515
+ }
516
+ );
517
+ } else {
518
+ result += \`....No versions added yet\n\`;
519
+ }
520
+
521
+ } else {
522
+ result = \`Unknown title ${desired_title}\n\`;
523
+ }
524
+ ENDJAVASCRIPT
525
+
526
+ extract_json_data "$jscode"
527
+ }
528
+
529
+
530
+ # $1 = the name of a title
531
+ # $2 = the name of a version
532
+ # Output the text to be displayed for the 'details' command for a version
533
+ #######################################
534
+ function details_for_version() {
535
+ debug "Getting details for version $2 of title $1 ..."
536
+ local desired_title=$1
537
+ local desired_version=$2
538
+ local jscode
539
+
540
+ read -r -d '' jscode <<ENDJAVASCRIPT
541
+ if (parsed_data.titles["${desired_title}"]) {
542
+ var title_data = parsed_data.titles["${desired_title}"];
543
+ var version_data = title_data.versions.find(
544
+ function(value, index, array) {
545
+ return value.version == "${desired_version}";
546
+ }
547
+ );
548
+
549
+ if (version_data) {
550
+ result = \`Details of version '\${version_data.version}' of title \${title_data.title}\n\`;
551
+ result += \`..Publish Date: \${version_data.publish_date}\n\`;
552
+ result += \`..Added to Xolo: \${version_data.creation_date} by \${version_data.created_by}\n\`;
553
+ if (version_data.pilot_groups.length > 0) result += \`..Pilot Groups: \${version_data.pilot_groups}\n\`;
554
+
555
+ result += \`..Minimum OS Version: \${version_data.min_os}\n\`;
556
+ if (version_data.max_os) result += \`..Maximum OS Version: \${version_data.max_os}\n\`;
557
+ result += \`..Requires Reboot: \${version_data.reboot}\n\`;
558
+ result += \`..Standalone: \${version_data.standalone}\n\`;
559
+ if (version_data.killapps.length > 0) result += \`..KillApps: \${version_data.killapps}\n\`;
560
+
561
+ result += \`..Status: \${version_data.status}\n\`;
562
+ if (version_data.status == 'released') {
563
+ result += \`..Released: \${version_data.release_date} by \${version_data.released_by}\n\`;
564
+ if (title_data.release_groups.length > 0) result += \`..Release Groups: \${title_data.release_groups}\n\`;
565
+ }
566
+
567
+ if (version_data.status == 'deprecated') {
568
+ result += \`..Deprecated: \${version_data.deprecation_date} by \${version_data.deprecated_by}\n\`;
569
+ }
570
+
571
+ if (version_data.status == 'skipped') {
572
+ result += \`..Skipped: \${version_data.skipped_date} by \${version_data.skipped_by}\n\`;
573
+ }
574
+
575
+
576
+
577
+ } else {
578
+ result = \`Unknown version '${desired_version}' for title ${desired_title}\`;
579
+ }
580
+
581
+ } else {
582
+ result = \`Unknown title ${desired_title}\`;
583
+ }
584
+ ENDJAVASCRIPT
585
+
586
+ extract_json_data "$jscode"
587
+ }
588
+
589
+ # $1 = the name of a title
590
+ # Outputs the version script for the title
591
+ # or empty string if not set, or if no matching title
592
+ #######################################
593
+ function version_script_for_title() {
594
+ debug "Getting version script for title $1..."
595
+ local desired_title=$1
596
+ local jscode
597
+
598
+ read -r -d '' jscode <<ENDJAVASCRIPT
599
+ if (parsed_data.titles["${desired_title}"]) {
600
+ var vscript = parsed_data.titles["${desired_title}"]["version_script"];
601
+ result = vscript ? vscript : ''
602
+ } else {
603
+ result = '';
604
+ }
605
+ ENDJAVASCRIPT
606
+ extract_json_data "$jscode"
607
+ }
608
+
609
+ # $1 = the name of a title
610
+ # Outputs the app_name and app_bundle_id for the title, semi-colon separated
611
+ # or empty string if not set, or if no matching title
612
+ #######################################
613
+ function app_data_for_title() {
614
+ debug "Getting app data for title $1..."
615
+ local desired_title=$1
616
+ local jscode
617
+
618
+ read -r -d '' jscode <<ENDJAVASCRIPT
619
+ if (parsed_data.titles["${desired_title}"]) {
620
+ var appname = parsed_data.titles["${desired_title}"]["app_name"];
621
+ var bundleid = parsed_data.titles["${desired_title}"]["app_bundle_id"];
622
+ if (appname && bundleid) {
623
+ result = \`\${appname};\${bundleid}\`;
624
+ } else {
625
+ result = '';
626
+ }
627
+ } else {
628
+ result = '';
629
+ }
630
+ ENDJAVASCRIPT
631
+ extract_json_data "$jscode"
632
+ }
633
+
634
+ # $1 = the name of a title
635
+ # Outputs the expiration period (integer of days) for the title
636
+ # or empty string if not set, or if no matching title
637
+ #######################################
638
+ function expiration_for_title() {
639
+ debug "Getting expiration for title $1..."
640
+ local desired_title=$1
641
+ local jscode
642
+
643
+ read -r -d '' jscode <<ENDJAVASCRIPT
644
+ if (parsed_data.titles["${desired_title}"]) {
645
+ var expiration = parsed_data.titles["${desired_title}"]["expiration"];
646
+ result = expiration ? expiration : ''
647
+ } else {
648
+ result = '';
649
+ }
650
+ ENDJAVASCRIPT
651
+ extract_json_data "$jscode"
652
+ }
653
+
654
+ # $1 = the name of a title
655
+ # Outputs the expire paths for the title, one per line
656
+ # or empty string if not set, or if no matching title
657
+ #######################################
658
+ function expire_paths_for_title() {
659
+ debug "Getting expire paths for title $1..."
660
+ local desired_title=$1
661
+ local jscode
662
+
663
+ read -r -d '' jscode <<ENDJAVASCRIPT
664
+ if (parsed_data.titles["${desired_title}"]) {
665
+ var expire_paths = parsed_data.titles["${desired_title}"]["expire_paths"];
666
+ result = expire_paths.length > 0 ? expire_paths.join("\n") : ''
667
+ } else {
668
+ result = '';
669
+ }
670
+ ENDJAVASCRIPT
671
+ extract_json_data "$jscode"
672
+ }
673
+
674
+ # $1 = the name of a title
675
+ # Outputs the currently released version for the title
676
+ # or empty string if not set, or if no matching title
677
+ #######################################
678
+ function released_version_for_title() {
679
+ debug "Getting released version for title $1..."
680
+ local desired_title=$1
681
+ local jscode
682
+
683
+ read -r -d '' jscode <<ENDJAVASCRIPT
684
+ if (parsed_data.titles["${desired_title}"]) {
685
+ var released_version = parsed_data.titles["${desired_title}"]["released_version"];
686
+ result = released_version ? released_version : ''
687
+ } else {
688
+ result = '';
689
+ }
690
+ ENDJAVASCRIPT
691
+ extract_json_data "$jscode"
692
+ }
693
+
694
+ # $1 = the name of a title
695
+ # $2 = the name of a version
696
+ # Output status of a version
697
+ #######################################
698
+ function status_for_version() {
699
+ debug "Getting status for version $2 of title $1 ..."
700
+ local desired_title=$1
701
+ local desired_version=$2
702
+ local jscode
703
+
704
+ read -r -d '' jscode <<ENDJAVASCRIPT
705
+ if (parsed_data.titles["${desired_title}"]) {
706
+ var title_data = parsed_data.titles["${desired_title}"];
707
+ var version_data = title_data.versions.find(
708
+ function(value, index, array) {
709
+ return value.version == "${desired_version}";
710
+ }
711
+ );
712
+
713
+ if (version_data) {
714
+ result = version_data.status;
715
+ } else {
716
+ result = \`Unknown version '${desired_version}' for title ${desired_title}\`;
717
+ }
718
+
719
+ } else {
720
+ result = \`Unknown title ${desired_title}\`;
721
+ }
722
+ ENDJAVASCRIPT
723
+
724
+ extract_json_data "$jscode"
725
+ }
726
+
727
+ # Run a Jamf policy trigger
728
+ # $1 = the policy trigger to run
729
+ # $2 = any extra options to pass to the jamf command
730
+ # be_verbose() will automatically be honored
731
+ #######################################
732
+ function run_jamf_policy_trigger() {
733
+ local policy_trigger=$1
734
+ local jamf_options=$2
735
+ local jamf_verbose=''
736
+ local cmd
737
+
738
+ be_verbose && jamf_verbose='-verbose'
739
+
740
+ cmd="$JAMF policy -trigger $policy_trigger $jamf_options $jamf_verbose"
741
+ tempfile=$(mktemp /tmp/xolo-policy.XXXXXXXX)
742
+
743
+ debug "Running jamf policy command: $cmd"
744
+ eval "$cmd" | tee "$tempfile"
745
+
746
+ policy_output=$(<"$tempfile")
747
+ rm -f "$tempfile"
748
+
749
+ [[ $policy_output =~ 'Submitting log to https://' ]] && return 0
750
+
751
+ # callers can examine $policy_output if they want to see what happened
752
+ return 1
753
+ }
754
+
755
+ # Refresh the client data
756
+ ###############################
757
+ function refresh_client_data() {
758
+
759
+ # if we've already refreshed during this run, we're done
760
+ [[ -n "$client_data_refreshed" ]] && return
761
+
762
+ [[ "$command" == 'refresh' ]] && say "Refreshing client data..." || debug "Refreshing client data..."
763
+
764
+ run_jamf_policy_trigger $UPDATE_CLIENT_DATA_TRIGGER
765
+
766
+ client_data_refreshed=1
767
+ }
768
+
769
+ # validate that the title exists
770
+ ###############################
771
+ function validate_title() {
772
+ # if we've already validated the title, we're done
773
+ [[ -n "$title_is_valid" ]] && return
774
+
775
+ debug "Validating title: $title"
776
+
777
+ # die if no title given
778
+ [[ -z "$title" ]] && die "No title given.\nUsage: $USAGE\nUse --help for more information."
779
+
780
+ # make sure the JSON data file is up to date, if we are root
781
+
782
+ [[ $EUID -eq 0 ]] && refresh_client_data
783
+
784
+ # die if no such title
785
+ # all titles in an array
786
+ IFS=$'\n' all_titles=($(all_xolo_titles))
787
+
788
+ # index will be zero if the title is not in the array
789
+ [[ ${all_titles[(Ie)$title]} -eq 0 ]] && die "No such title: $title"
790
+ title_is_valid=1
791
+ }
792
+
793
+ # validate that the version exists
794
+ ###############################
795
+ function validate_version() {
796
+ # if we've already validated the version, we're done
797
+ [[ -n "$version_is_valid" ]] && return
798
+
799
+ validate_title
800
+ debug "Validating version: $version"
801
+
802
+ # die if no version given
803
+ [[ -z "$version" ]] && die "No version given.\nUsage: $USAGE\nUse --help for more information."
804
+
805
+ # die if no such version
806
+ all_versions=$(versions_for_title $title)
807
+ debug "All versions for title $title:\n$all_versions"
808
+ # [[ $all_versions =~ (^|\n)$version($|\n) ]] || die "No such version: $version"
809
+ echo "$all_versions" | grep -q "^$version$" || die "No such version: $version"
810
+ version_is_valid=1
811
+ }
812
+
813
+ # Install all the items on the command line, which may be titles or specific versions of titles
814
+ #################################
815
+ function install_cli_args() {
816
+ debug "Installing Targets: $targets"
817
+ for item in $targets ; do
818
+ parse_title_and_version "$item"
819
+ install
820
+ done
821
+ }
822
+
823
+
824
+ # Install a title, or a specific version thereof
825
+ ###############################
826
+ function install() {
827
+
828
+ validate_title
829
+
830
+ # if no version is given, we'll find the current release
831
+ if [[ -z "$version" ]] ; then
832
+ version=$(released_version_for_title $title)
833
+ [[ -z "$version" ]] && die "No current release for title: $title. Specify a version to install."
834
+
835
+ # This trigger always installs the current release via a single policy for the title
836
+ install_trigger="xolo-${title}-install"
837
+ else
838
+ # this is the trigger for a specific version of a title.
839
+ install_trigger="xolo-${title}-${version}-manual-install"
840
+ fi
841
+
842
+ validate_version
843
+
844
+ if [[ -n "$version" ]] ; then
845
+ thing_being_installed="version $version of title $title"
846
+ else
847
+ thing_being_installed="released version of title $title"
848
+ fi
849
+ say "Installing $thing_being_installed"
850
+
851
+ if run_jamf_policy_trigger "${install_trigger}" ; then
852
+ run_recon
853
+ say "Done, installed $thing_being_installed"
854
+ else
855
+ if [[ $policy_output =~ 'No policies were found for the' ]] ; then
856
+ die "Title $title is excluded for this computer or is not installable via xolo"
857
+ else
858
+ die "Install of title $title failed."
859
+ fi
860
+ fi
861
+ }
862
+
863
+ # uninstall CLI args, which are just titles since versions don't matter for uninstalling
864
+ ##############################
865
+ function uninstall_cli_args() {
866
+ debug "Uninstalling Targets: $targets"
867
+ for item in $targets ; do
868
+ parse_title_and_version "$item"
869
+ uninstall
870
+ done
871
+ }
872
+
873
+ # Unnstall a title
874
+ # This might not do anything if the title is not uninstallable,
875
+ # or if the title is not installed
876
+ ###############################
877
+ function uninstall() {
878
+ validate_title
879
+
880
+ details_for_title $title | grep -q 'Uninstallable: true' || die "Title $title is not uninstallable via xolo"
881
+
882
+ say "Uninstalling title $title..."
883
+
884
+ if run_jamf_policy_trigger "xolo-${title}-uninstall" ; then
885
+ run_recon
886
+ say "Done, title $title uninstalled"
887
+ else
888
+ if [[ $policy_output =~ 'No policies were found for the' ]] ; then
889
+ die "Title $title is not uninstallable via xolo"
890
+ else
891
+ die "Uninstall of title $title failed."
892
+ fi
893
+ fi
894
+ }
895
+
896
+ # run a jamf recon if requested
897
+ #
898
+ #############################
899
+ function run_recon() {
900
+ [[ -n "$do_recon" ]] || return 0
901
+
902
+ say 'Running jamf recon...'
903
+ if be_verbose ; then
904
+ $JAMF recon -verbose
905
+ else
906
+ $JAMF recon
907
+ fi
908
+ }
909
+
910
+ # Update installed titles or install new ones
911
+ # scoped to this computer.
912
+ # This is just running 'jamf policy' with its default
913
+ # trigger "recurring check-in".
914
+ ###############################
915
+ function update() {
916
+ say "Updating installed titles, or installing newly scoped ones..."
917
+
918
+ debug "Running jamf policy..."
919
+ if be_verbose ; then
920
+ $JAMF policy -verbose
921
+ else
922
+ $JAMF policy
923
+ fi
924
+ }
925
+
926
+ # List all known titles and their versions and statuses
927
+ ###############################
928
+ function list_all_titles() {
929
+ say "All Titles known to Xolo..."
930
+
931
+ if [[ -n "$no_versions" ]] ; then
932
+ all_xolo_titles
933
+ return 0
934
+ fi
935
+
936
+ all_xolo_titles_with_versions
937
+ }
938
+
939
+ # Show detailed information about all titles, or specific versions thereof, given on the command line
940
+ ########################
941
+ function show_details_cli_args() {
942
+ debug "Showing details for targets: $targets"
943
+ for item in $targets; do
944
+ parse_title_and_version "$item"
945
+ show_details
946
+ done
947
+ }
948
+
949
+ # Show detailed information about a title or a version thereof
950
+ ###############################
951
+ function show_details() {
952
+ debug "Showing details for title '$title' and version '$version'..."
953
+ validate_title
954
+
955
+ if [[ -z "$version" ]] ; then
956
+ details_for_title $title
957
+ else
958
+ validate_version
959
+ details_for_version $title $version
960
+ fi
961
+ }
962
+
963
+ # List all installed titles
964
+ ###############################
965
+ function list_installed_titles() {
966
+ local ttl
967
+ local app_data
968
+
969
+
970
+ verbose "Locating installed titles..."
971
+
972
+ # populate the installed_apps associative array
973
+ get_installed_apps
974
+
975
+ while IFS= read -r ttl; do
976
+ app_data=$(app_data_for_title $ttl)
977
+
978
+ if [[ -n "$app_data" ]] ; then
979
+ debug "App Data: '$app_data'"
980
+ display_title_if_installed_by_app_data $ttl "$app_data"
981
+ else
982
+ display_title_if_installed_by_version_script $ttl
983
+ fi
984
+ done <<<"$(all_xolo_titles)"
985
+ return 0
986
+ }
987
+
988
+ # given a title and some app data (name, bundle id)
989
+ # output a line if its installed, optionally with the version
990
+ # $1 is the semicolon separated app data, name:bundle_id
991
+ ###############################
992
+ function display_title_if_installed_by_app_data() {
993
+ local ttl=$1
994
+ local app_data=$2
995
+ local app_name
996
+ local app_bundle_id
997
+ local inst_app_name
998
+ local inst_app_vers
999
+ local data
1000
+ local parts
1001
+
1002
+
1003
+ parts=(${(s/;/)app_data})
1004
+ app_name=$parts[1]
1005
+ app_bundle_id=$parts[2]
1006
+ debug "Split App Data: '$app_bundle_id' -> '$app_name'"
1007
+
1008
+ # This shouldn't happen but...
1009
+ [[ -n "$app_name" && -n "$app_bundle_id" ]] || return
1010
+
1011
+ # is this bundle id installed?
1012
+ [[ -n $installed_apps[$app_bundle_id] ]] || return
1013
+
1014
+ data=$installed_apps[$app_bundle_id]
1015
+ parts=(${(s/;/)data})
1016
+ inst_app_name=$parts[1]
1017
+ inst_app_vers=$parts[2]
1018
+ debug "Split Installed Data: '$inst_app_name' -> '$inst_app_vers'"
1019
+
1020
+ # no go if the app name doesn't match
1021
+ [[ "$inst_app_name" == "$app_name" ]] || return
1022
+
1023
+ if [[ -n "$no_versions" ]] ; then
1024
+ echo $ttl
1025
+ else
1026
+ echo "$ttl ($inst_app_vers)"
1027
+ fi
1028
+ }
1029
+
1030
+
1031
+ # Given a title, use its version script to see if its installed,
1032
+ # and if so, output a line for it, optionally with the version
1033
+ # $1 is the title
1034
+ ###############################
1035
+ function display_title_if_installed_by_version_script() {
1036
+ local title=$1
1037
+ local version_script=$(version_script_for_title $title)
1038
+ local app_vers=
1039
+
1040
+ [[ -n "$version_script" ]] || return
1041
+
1042
+ # Save it to a file, make it executable, run it capturing the output, then delete the file
1043
+ tmp_file=$(mktemp -t xolo.$title)
1044
+ touch "$tmp_file"
1045
+ chmod 700 "$tmp_file"
1046
+ echo "$version_script" > "$tmp_file"
1047
+
1048
+ debug "Running version script for $title from $tmp_file"
1049
+ vsout=$("$tmp_file" 2>/dev/null)
1050
+ debug "Version Script Output: '$vsout'"
1051
+
1052
+ rm -f "$tmp_file"
1053
+
1054
+ [[ -n "$vsout" ]] || return
1055
+
1056
+ # the output should be something like
1057
+ # <result>version</result> if installed, and
1058
+ # <result></result> if not installed
1059
+ [[ "$vsout" =~ '<result>(.*)</result>' ]] && app_vers=$match[1]
1060
+ [[ -n "$app_vers" ]] || return
1061
+
1062
+ if [[ -n "$no_versions" ]] ; then
1063
+ echo $ttl
1064
+ else
1065
+ echo "$ttl ($app_vers)"
1066
+ fi
1067
+ }
1068
+
1069
+ # Expire the title given on the command line, if
1070
+ # - it has an expiration period set
1071
+ # - it has defined expire paths
1072
+ # - none of the paths are running right now
1073
+ # - none of the paths have been used in the defined period
1074
+ ###############################
1075
+ function expire() {
1076
+ validate_title
1077
+
1078
+ local now=$(date '+%s')
1079
+ local expiration=$(expiration_for_title $title)
1080
+ local exp_secs=$(( $expiration * 24 * 3600 ))
1081
+ local expire_paths
1082
+ local victim_path
1083
+ local must_have_been_used_since=$(( $now - $exp_secs ))
1084
+ local last_use_epoch
1085
+ local last_use
1086
+
1087
+ # return if no expiration period
1088
+ if [[ -z "$expiration" ]] ;then
1089
+ say "Title $title is not expirable: No expiration period."
1090
+ return
1091
+ fi
1092
+ # this gives us an array of paths
1093
+ IFS=$'\n' expire_paths=($(expire_paths_for_title $title))
1094
+ # return if empty
1095
+ if [[ ${#expire_paths} -eq 0 ]] ; then
1096
+ say "Title $title is not expirable: no expire paths."
1097
+ return
1098
+ fi
1099
+
1100
+ # loop thru the paths and see if any have been used recently
1101
+ for victim_path in $expire_paths ; do
1102
+
1103
+ # don't expire if any expire path doesn't exist
1104
+ if ! [[ -e "$victim_path" ]] ; then
1105
+ say "Expiration Path: '$victim_path' doesn't exist, not expiring $title."
1106
+ return
1107
+ fi
1108
+
1109
+ # if any of the paths are running, we're done
1110
+ if [[ -n $(/usr/bin/pgrep -f "$victim_path") ]] ; then
1111
+ say "Title $title is not expirable right now: $victim_path is running."
1112
+ return
1113
+ fi
1114
+
1115
+ # if any of the paths have been used in the defined period, we're done
1116
+ # The Apple Developer Site says: kMDItemLastUsedDate is the date and time
1117
+ # that the file was last used. This value is updated automatically by
1118
+ # LaunchServices everytime a file is opened by double clicking, or by asking
1119
+ # LaunchServices to open a file.
1120
+ last_use=$(/usr/bin/mdls -name kMDItemLastUsedDate "$victim_path" -raw)
1121
+ # +> 2024-11-25 23:10:00 +0000
1122
+
1123
+ # use install time if no last use time
1124
+ if ! [[ "$last_use" =~ '^[[:digit:]]{4}-' ]] ; then
1125
+ debug "No last use time for $victim_path, using install time."
1126
+ last_use=$(/usr/bin/mdls -name kMDItemDateAdded "$victim_path" -raw)
1127
+
1128
+ # not installed if mdls fails or returns nothing
1129
+ # so don't expire it
1130
+ if [[ "$last_use" =~ '^[[:digit:]]{4}-' ]] || [[ -z "$last_use" ]] ; then
1131
+ say "Title $title is not expirable: $victim_path has no last use or install time."
1132
+ return
1133
+ fi
1134
+ fi
1135
+
1136
+ last_use_epoch=$(/bin/date -j -f "%Y-%m-%d %H:%M:%S %z" "$last_use" +"%s")
1137
+
1138
+ # if the last use time is more recent than the expiration period,
1139
+ # for any path, we're done
1140
+ if [[ "$last_use_epoch" -gt "$must_have_been_used_since" ]] ; then
1141
+ say "Title $title is not expirable right now: $victim_path was used recently."
1142
+ return
1143
+ fi
1144
+ done # for victim_path in $expire_paths
1145
+
1146
+ # if we're here, we can expire the title
1147
+ say "Expiring title $title..."
1148
+ uninstall
1149
+ }
1150
+
1151
+
1152
+
1153
+
1154
+ # MAIN
1155
+ ###############################
1156
+ ###############################
1157
+
1158
+ function main() {
1159
+ # Parse the command line
1160
+ parse_cli "$@"
1161
+
1162
+ # If the user asked for help, show it and exit
1163
+ if [[ -n "$show_help" ]] ; then
1164
+ show_help
1165
+ exit 0
1166
+ fi
1167
+
1168
+ # If the user asked for the xolo version, show it and exit
1169
+ if [[ -n "$show_xolo_version" ]] ; then
1170
+ echo "$XOLO_VERSION"
1171
+ exit 0
1172
+ fi
1173
+
1174
+ if [[ "$command" != "refresh" ]] ; then
1175
+ # If we're not refreshing, we need to have the client data file
1176
+ # and it needs to be up to date
1177
+ [[ -f "$CLIENT_DATA_JSON_FILE" ]] || die "No client data file found at $CLIENT_DATA_JSON_FILE.\nPlease run the 'refresh' command to update the client data."
1178
+ fi
1179
+
1180
+ [[ -z "$command" ]] && die "No command given.\nUsage: $USAGE\nUse --help for more information."
1181
+
1182
+ # confirm we are root for those commands that need it
1183
+ must_be_root
1184
+
1185
+ case "$command" in
1186
+ install|i)
1187
+ debug "processing command 'install'"
1188
+ install_cli_args
1189
+ ;;
1190
+ uninstall|u)
1191
+ debug "processing command 'uninstall'"
1192
+ uninstall_cli_args
1193
+ ;;
1194
+ update|U)
1195
+ debug "processing command 'update'"
1196
+ update
1197
+ ;;
1198
+ refresh|r)
1199
+ debug "processing command 'refresh'"
1200
+ # if 'r' reset to 'refresh' so we can use it in the run_jamf_policy_trigger
1201
+ command=refresh
1202
+ refresh_client_data
1203
+ ;;
1204
+ list-titles|lt)
1205
+ debug "processing command 'list_titles'"
1206
+ list_all_titles
1207
+ ;;
1208
+ list-installed|li)
1209
+ debug "processing command 'list_installed'"
1210
+ list_installed_titles
1211
+ ;;
1212
+ details|d)
1213
+ debug "processing command 'details'"
1214
+ show_details_cli_args
1215
+ ;;
1216
+ expire|e)
1217
+ debug "processing command 'expire'"
1218
+ expire
1219
+ ;;
1220
+ help|h)
1221
+ debug "processing command 'help'"
1222
+ show_help
1223
+ ;;
1224
+ *)
1225
+ die "Unknown command: $command\nUsage: $USAGE\nUse --help for more information."
1226
+ ;;
1227
+ esac
1228
+
1229
+ }
1230
+
1231
+ # RUN!
1232
+ ###########################
1233
+ main "$@"