xolo-server 1.0.0 → 1.0.1

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