Exploring Unicode in macOS with clui

My new tool clui, pronounced “clue-ee” offers Command Line Unicode Info with the ability to export to a variety of formats like CSV, JSON, YAML, RTF and more. While I’ve written a few macOS command line tools geared to the Mac Admin like jpt the JSON power tool, ljt the little JSON tool, shui for easily adding AppleScript dialogs to your shell script, and most recently shef a Unicode text encoder and formatter for shell scripters. This is one is almost “just for fun” although you might find some practical uses for it. Writing shef opened my eyes to the stunning amount of detail and craftsmanship in macOS’ Unicode-aware fonts, which comprise not just the alphabets of the world but signs, symbols, and even Egyptian hieroglyphics! While macOS’s built-in Character Viewer does a pretty good job to group and display these characters it’s a painstakingly manual process if you want to get info on a range of characters. I hope clui makes it fun and easy to poke around the vast Unicode neighborhood.

If you don’t feel like reading you can watch this.

Starting with Character Viewer

First let’s take a look at Character View, perhaps you don’t know some of it’s features. If you quickly press the dedicated “globe” 🌐 button on newer Macs (or the keyboard combo Control-Command-Spacebar on older models) it will likely open up Character Viewer in it’s default mini-sized version with preset categories along the bottom. You can click on a symbol or character and it will insert the text in your current app. The real fun begins when you click the little icon in the upper right to expand the view.

The window will expand to show more information: The code point(s) in hexadecimal (U+hhhh), the UTF-8 encoding bytes, and related characters. You will need to double-click these to insert them into your current app.

You can also Customize the List of categories that appear in the left column by clicking the encircled ellipsis…

The one that has everything in it is Unicode under the Code Tables group at the bottom of the list.

Jackpot!

You can right-click or control-click on a character to Copy Character Info into the clipboard. In the example below, we discover that what most Mac folks would call ⌘ “Command” is also known as the “Place of Interest Sign”. Whaddya know!

This is all well and good but who’d want to do that for thousands of characters?! What if someone wanted this info at the command line? It got me thinking: There’s Got to Be a Better Way!β„’

Searching for the Source

The first order of business was looking for where macOS kept it’s naming information and if it was possible to extract that information with command line tools. It ended up being in two files, a SQLite database and a plist. Here’s their full paths:

#Single code point characters and Unicode symbols
/System/Library/Input Methods/CharacterPalette.app/Contents/Resources/CharacterDB.sqlite3
#plist of single and multiple code point Emoji
/System/Library/PrivateFrameworks/CoreEmoji.framework/Versions/A/Resources/en.lproj/AppleName.strings

There is some overlap with CharacterDB.sqlite3 and AppleName.strings where Apple choose to use different phrasing for an Emoji vs. Unicodes name, but in general the former has single code point entries, while the latter has both single and the multi-codepoint Emoji sequences. clui will report on both, putting a semi-colon in between each version, you can also output discretely with the -D option. Descriptions/info fields are converted from uppercase to lowercase BECAUSE WHO LIKES GETTING YELLED AT?! πŸ™‰ Although you can preserve case with -p which can help with deciphering the internal (and unlabeled!) columns Apple uses in the descriptions of the CJK Ideographic ranges.

clui in Action: Practical Examples

Simple Lookups

If you’d like to see what all the options and modes run clui -u to get the “usage” output, or take a look at on clui’s GitHub page. To start with, clui is built to ingest both “regular” characters and also representation of Unicode code points in hexadecimal in the style of: U+hhhhh or 0xhhhhh. Ranges can also be specified by simply adding a hyphen between to characters or codepoints and it can both ascend and descend. The default output is CSV.

Apple doesn’t want to call anyone a nerd apparently, but that’s OK I embrace it.

For multiple code point emoji you can enclose the code point representations in quotes. Spaces within quotes are only used delimit each code point and are not part of the composite character.

The -X expansion option will display the all the code points together, then break out each component

-x will expand the input and break out each code point without showing the composite character

Working with Categories and Groups

clui can tap into categories and groups by leveraging the the plists inside the Resources folder of CharacterPallette.app. These plists contain a mix of 0x code point representations and literal characters. The list option -L takes the upper and lowercase arguments of c or g for exactly what you think, categories and groups! -Lc gets categories marking those with internal subsections with an asterisk * and -LC will expand those categories to include the subsections within. All list outputs are CSV and includes a header row, -h will suppress headers. In this excerpted example below you can see Arrows contains a number of subsections:

clui acts on a categories when you use the -C option and input one or more categories. If no subsection is specified the complete category will be output. Some can be quite large and take several minutes to output! If you are outputting RTF or JSON and redirecting to a file, if you interrupt with Control-C the output will be properly closed up, so it will still be valid.

As you can see at the bottom of the list, not all characters have renderings. However, if you double click those question mark glyphs ⍰ to select and copy them (CSV is nice that way), you will get that exact character. I used the apl “quad question” character (U+2370) above to get something close to it for this example, but don’t let the generic visual representation fool you, it is unique. You can use Character Viewer to see if any other fonts have alternate graphical representations, since Terminal is using only the currently selected font to display output. Update: It will fall back to a font that has a representation if needed. I recommend the free GNU Unifont for “Glyphs above the Unicode Basic Multilingual Plane”, which fills in some of the gaps of Apple fonts (like the LCD-like segmented numbers U+1FBF0-U+1FBF9)

Groups are about the same thing as categories except groups are comprised of multiple categories. -Lg lists all groups and -LG will expand the constituent categories within.

You can use -G like -C specifying one or more groups. If you include a category name (comma delimited) after the group, it’ll simply report that category as if you’d used it solely with -C. Here’s an example of the first group AdditionalModernScripts and the member category CanadianAboriginalSyllabics . For variety I’ve added -h to hide the header row from the CSV output.

CSV Looks Great in QuickLook

Just in case you didn’t know, Quicklook will display files with a csv extension really nicely. Here’s the CanadianAboriginalSyllabics above as seen in Quicklook, you can even select characters from within Quicklook, pretty nice!

CSV Can Look Even Better in Numbers

If you want to work with CSV in Numbers it will do quite nicely. Here’s a little tip for better legibility:

  • Select column A from the top of the column
  • Command ⌘ click on A1 to deselect it
  • Format the text to a larger size (like 50 points or higher)
  • Adjust the column width a bit wider
  • Save it as a .numbers file to retain formatting
Some of these Emoji will make you hungry

Pro Tip: Quicklook will not let you select any text from a Numbers document! However if you click and drag anywhere in the Quicklook contents to the Desktop (or into Preview), it will export a seamless PDF with no page breaks! Then you can select text from the PDF in QuickLook. Who knew?! Now back to clui features!

Get a Good Look at Those Emojis in Rich Text Format!

A late entry feature to clui is RTF (Rich Text Format) output. This enables clui to present the characters in larger sizes without requiring additional work from you (as seen above in Numbers)! The format is the same as the “plain” output (-Op) which does not label the fields, simply use the -Or option

Your best bet with RTF output is to either redirect it to a file like this: clui -Or -C Emoji > Emoji.rtf or pipe it into pbcopy like so: clui -Or -C Emoji | pbcopy. There’s a neat feature in pbcopy that detects the RTF header data and allows you to paste into TextEdit as rich text. You can also specify the font sizes withthis option: -f <char size, info size>

Searching by Description

clui can also search descriptions for multiple words and phrases. In this example I’ll search for magic, castle, and “clock face” using the -Sd (search descriptions) option. If I had searched just for clock I’d have also gotten hits for “clockwise arrows” since it search for substrings.

Searching by Character

Now, if you search for the usage of a single alphabetical character you’ll probably get one hit but macOS also has a database of “related characters” which are similar look-alike letters. You know, like the Subject lines of spam: “𝔅𝗒𝔾𝕆 π’π–†π“΅π™š β€Ό”. Let’s use -SC to search for "a" plus related characters. I’ve trimmed the output to get some of the more interesting characters in this screenshot

When you start searching for symbols you’ll start discovering Emojis constructed using existing symbols and zero-width joiners (ZWJ, U+200D), these are called ZWJ Sequences (and ZWJ is apparently pronounced “zwidge”) . Let’s use -Sc to search for anything with the female sign ♀.

Let’s examine “woman surfer” (BTW: 100 Foot Wave Season 2 is awesome!) with the -X option to expand all component code points with the complete glyph as-is at the top:

The first line has the Emoji sequence as-is, then each individual component that comprises it: a gender neutral surfer (U+1F3CF), a ZWJ (U+200D), the female sign (U+2640), and variation selector 16 (U+FE0F). You may also notice one of the quirks of Terminal: Sometimes pasted input does not fully render as a unified Emoji!

Fitzpatrick Modifiers for Skin Color

To be honest I’d never heard of the Fitzpatrick scale before working on clui! It’s simply a scale of 6 types of skin color. The Unicode modifier combines Types 1 & 2 into U+1F3FB. Let’s demonstrate a search by description and then again with their ranges. Simply specify the range using standard Unicode notiation U+hhhhh with a hyphen in between.

If you search for an Emoji with a Fitzpatrick Modifier you won’t get a hit in the databases. As a convenience clui will detect this and remove the modifier to get the description. Using the -F will also display the sequence without the modifier. We can combine it with -X for expanded output with a a summary.

As you can see the sequence without the modifier is shown, then the original sequence with the Fitzpatrick modifier then each component part: surfer, Fitzpatrick modifier type-6, a ZWJ, female sign, and a variation selector.

On Variation Selectors

To be honest I’d never known about variation selectors before working on this, more info on Emoji Presentation Sequences can be found here. The two most common variation selectors are pretty easy to understand: U+FE0E is “variation selector 15” and it is used to explicitly specify the text/non-graphic version and U+FE0F, “variation selector 16” gives you the emoji/graphic version. Watch (U+231A) is a good example of this. It’s at the discretion of the OS on how to render a glyph. In this case U+231A is rendered in the emoji style of an Apple Watch⌚️by default. When combined with U+FE0E, it turns into ye olde Mac OS watch ⌚︎, this is the “text version”. Adding U+FE0F does nothing to change the appearance since it was already rendered in the emoji style without it.

If you’d like to search for every character with a variation selector that is tracked in macOS’s database, you can run this query: clui -Sc U+FE0E U+FE0F A definitive list can be found here.

Encoding Options

So far we’ve just seen the default uppercase hexadecimal (-Eh) UTF-8 encoding. In the vein of shef, clui can output in various styles of encoding: \x hex escapes (-Ex), octal \nnn (-Eo), leading zero octal \0nnn (-E0), UTF-16 Javascript encoding (-u) and zsh style UTF-32 \U code points (-EU).

Surfing safari, encoding party! πŸ€™

Formatting output

Besides the beautiful RTF output and functional CSV output, clui can also output characters simply space delimited, without any other data (-Oc).

JSON (-Oj) and JSON Sequences (-OJ) can be had as well. The difference between JSON and JSON Sequence is that JSON will be an array of objects, whereas JSON Sequences are JSON objects delimited with U+1E the “record separator” as ASCII calls it or “information separator two” as Unicode knows it to be and newlines. Both jq and jpt can handle JSON sequneces.

JSON (-Oj)
JSON Sequence (-OJ)

Lastly we have YAML (-Oy) the superset cousin of JSON. My JSON string encoder jse gets some use in clui to encode strings and descriptions for these output modes.

YAML, cousin of JSON

Making it work for you (and me)

I spent a lot of time trying to make clui work in an intuitive way, it replicates the core features of Character Viewer with plenty of bonus functionality thrown in. It may not be something you use daily, but it might come in handy when you get an email from let’s say, tim@Π°Ρ€Ρ€lΠ΅.com. You could run that string through clui in “expand” mode (-x) to analyze on each letter in the string. You might be surprised and perhaps disappointed that perhaps “Tim Apple” did not send you that email.

Cyrillic look-a-likes! πŸ”Ž

Or maybe you’d like to figure out the secrets of Zalgo Text or perhaps what characters are in Β―\_(ツ)_/Β― or make a catalog of Emoji in RTF: clui can do it! Head on over to the clui Github page and download the Release if you’d like to try it out on your (Monterey+) Mac, thanks!

Bonus

Since you made it down here, how about a one-liner that will create RTFs of all the Unicode categories? It will likely take several hours and will open a Finder window when finished.

#make RTFs of every Unicode category, this might take a few hours, if you want to cancel close the Terminal window
mkdir ~/Desktop/clui-rtfs; cd ~/Desktop/clui-rtfs; IFS=$'\n'; for category in $(clui -LC | grep Unicode,); do clui -Or -C "$category" > "$category.rtf"; done; open -R .

Determining eligible macOS versions via script

Every year Mac admins wonder which Macs will make the cut for the new MacOS. While it’s no mystery which models those are, if you’ve got Jamf you’ll be wondering how to best scope to those Macs so you can perhaps offer the upgrade in Self Service or alert the user to request a new Mac! One way to scope a Smart Group is with the Model Identifier criteria and the regex operator, like this one (I even chipped in!). It doesn’t require an inventory and results are near instant. Before I was any good at regex though, I took another route and made an Extension Attribute macOSCompatibility.sh, where the Mac reports back to Jamf. (It also has a CSV output mode for nerdy fun!) Both methods however require manual upkeep and are now somewhat complicated by Apple’s new use of the very generic Macxx,xx model identifier which doesn’t seem to follow the usual model name and number scheme of major version and minor form factor variants (on purpose me-thinks!). Let’s look at some new methods that don’t require future upkeep.

Using softwareupdate –list-full-installers

macOS Big Sur (11) introduced a new command softwareupdate --list-full-installers which shows all eligible installers available for download by the Mac running that command. The funny thing about this is that even though it is a Big Sur or newer feature, if the hardware is old enough, like a 2017, it offer versions all the way back to 10.13 High Sierra! Monterey added build numbers to the output and it can be easily reduced to just versions with awk:

softwareupdate --list-full-installers | awk -F 'Version: |, Size' '/Title:/{print $2}'

This one-liner can be made into a simple function. I’ve added a uniq at the end for case where two differing builds have the same version, like 10.15.7. Here’s that function in a script with a little version check: getSupportedMacOSVersions_SWU.sh

#!/bin/sh
: <<-LICENSE_BLOCK
getSupportedMacOSVersions_SWU - Copyright (c) 2022 Joel Bruner
Licensed under the MIT License
LICENSE_BLOCK

function getSupportedMacOSVersions_SWU()( 
#getSupportedMacOSVersions_SWU - uses softwareupdate to determine compatible macOS versions for the Mac host that runs this
	if [ "$(sw_vers -productVersion | cut -d. -f1)" -lt 11 ]; then echo "Error: macOS 11+ required" >&2; return 1; fi
	#get full installers and strip out all other columns
	softwareupdate --list-full-installers 2>/dev/null | awk -F 'Version: |, Size' '/Title:/{print $2}' | uniq
)

getSupportedMacOSVersions_SWU 
Output from Apple Silicon will never include 10.x versions

Software Update (SWU) Based Extension Attribute for Jamf

The possible inclusion of 10.x versions in the output complicates things a bit. In ye olden OS X days, the “minor version” (after the first period) acted more like the major versions of today! Still it can be done, and we will output any macOS 10.x versions, as if they are major versions like macOS 11, 12, 13, etc. Here’s getSupportedMacOSVersions-SWU-EA.sh

#!/bin/sh
: <<-LICENSE_BLOCK
getSupportedMacOSVersions-SWU-EA (Extension Attribute) - Copyright (c) 2022 Joel Bruner
Licensed under the MIT License
LICENSE_BLOCK

function getSupportedMacOSVersions_SWU()( 
#getSupportedMacOSVersions_SWU - uses softwareupdate to determine compatible macOS versions for the Mac host that runs this
	#[ "$(sw_vers -productVersion | cut -d. -f1)" -lt 11 ] && return 1
	if [ "$(sw_vers -productVersion | cut -d. -f1)" -lt 11 ]; then echo "Error: macOS 11+ required" >&2; return 1; fi
	#get full installers and strip out all other columns
	softwareupdate --list-full-installers 2>/dev/null | awk -F 'Version: |, Size' '/Title:/{print $2}'
)

#get our version
all_versions=$(getSupportedMacOSVersions_SWU)

#depending on the model (2020 and under) we might still get some 10.x versions 
if grep -q ^10 <<< "${all_versions}" ; then versions_10=$(awk -F. '/^10/{print $1"."$2}' <<< "${all_versions}")$'\n'; fi
#all the other major versions
version_others=$(awk -F. '/^1[^0]/{print $1}' <<< "${all_versions}")

#echo without double quotes to convert newlines to spaces
echo "<result>"$(sort -V <<< "${versions_10}${version_others}" | uniq)"</result>"
The venerable 2017 MacBook Pro has quite a span

Now if you wanted to make a Jamf Smart Group for those that could run macOS 13 you wouldn’t want to match 10.13 by accident. You could comment out the line in the script that matches versions beginning with ^10 or you could enclose everything in double quotes for the echo on the last line, so the newlines remained or you could use regex to match ([^.]|^)13 that is: not .13 or if the hardware is so new ^13 is at the very beginning of the string. As 10.x capable hardware fades away such regex sorcery shouldn’t be needed.

Using the Apple Software Lookup Service

“What’s the Apple Software Lookup Service?!”, you may be asking? I myself asked the same question! It’s a highly available JSON file that MDM servers can reference. If softwareupdate is acting up or hanging (and it’s been known to do so!), you have all you need in this JSON file to do a little sanity checking of softwareupdate too if you’d like. The URL is found in the Apple MDM Protocol Reference and it contains versions, models and their compatibility.

This method has far fewer patch and point versions than the softwareupdate method above and the OSes start with Big Sur (11). No 10.x versions are in the ASLS. There are two “sets” in ASLS, PublicAssetSets, which has only the newest release of each major version and AssetSets which has additional point releases (use the -a option for this one). plutil has quirky (IMO) rules for what it will and will not output as json but the raw output type can get around. It was introduced in Monterey and it can also be used to count array members, it’s goofy but manageable. Richard Purves has an article on that here. The code is generously commented, so I won’t expound upon it too much more, here’s getSupportedMacOSVersions_ASLS.sh

#!/bin/sh
: <<-LICENSE_BLOCK
getSupportedMacOSVersions_ASLS - Copyright (c) 2022 Joel Bruner
Licensed under the MIT License...
LICENSE_BLOCK

function getSupportedMacOSVersions_ASLS()( 
#getSupportMacOSVersions - uses Apple Software Lookup Service to determine compatible macOS versions for the Mac host that runs this
#  Options:
#  [-a] - to see "all" versions including prior point releases, otherwise only newest of each major version shown

	if [ "${1}" = "-a" ]; then
		setName="AssetSets"
	else
		setName="PublicAssetSets"
	fi

	#get Device ID for Apple Silicon or Board ID for Intel
	case "$(arch)" in
		"arm64")
			#NOTE: Output on ARM is Device ID (J314cAP) but on Intel output is Model ID (MacBookPro14,3)
			myID=$(ioreg -arc IOPlatformExpertDevice -d 1 | plutil -extract 0.IORegistryEntryName raw -o - -)
		;;
		"i386")
			#Intel only, Board ID (Mac-551B86E5744E2388)
			myID=$(ioreg -arc IOPlatformExpertDevice -d 1 | plutil -extract 0.board-id raw -o - - | base64 -D)
		;;
	esac	

	#get JSON data from "Apple Software Lookup Service" - https://developer.apple.com/business/documentation/MDM-Protocol-Reference.pdf
	JSONData=$(curl -s https://gdmf.apple.com/v2/pmv)

	#get macOS array count
	arrayCount=$(plutil -extract "${setName}.macOS" raw -o - /dev/stdin <<< "${JSONData}")

	#look for our device/board ID in each array member and add to list if found
	for ((i=0; i<arrayCount; i++)); do
		#if found by grep in JSON (this is sufficient)
		if grep -q \"${myID}\" <<< "$(plutil -extract "${setName}.macOS.${i}.SupportedDevices" json -o - /dev/stdin <<< "${JSONData}")"; then
			#add macOS version to the list
			supportedVersions+="${newline}$(plutil -extract "${setName}.macOS.${i}.ProductVersion" raw -o - /dev/stdin <<< "${JSONData}")"
			#only set for the next entry, so no trailing newlines
			newline=$'\n'
		fi
	done

	#echo out the results sorted in descending order (newest on top)
	sort -rV <<< "${supportedVersions}"
)

#pass possible "-a" argument
getSupportedMacOSVersions_ASLS "$@"
PublicAssetSets (top) vs. AssetSets (bottom)

The fact that this method requires Monterey for the plutil stuff didn’t agree with me, so I made a version that uses my JSON power tool (jpt) so it will work on all earlier OSes too. It’s a tad large (88k) but still runs quite fast: getSupportedMacOSVersions_ASLS-legacy.sh

Just as with the softwareupdate based function, the same can be done to reduce the output to only major versions and since it is v11 and up, a simple cut will do!

#major versions only, descending, line delimited
getSupportedMacOSVersions_ASLS | cut -d. -f1 | uniq

#major versions only ascending
echo $(getSupportedMacOSVersions_ASLS | cut -d. -f1 | sort -n | uniq)

ASLS Based Extension Attribute

The Apple Software Lookup Service (ASLS) JSON file itself doesn’t care what version of macOS a client is on, but the methods in plutil to work with JSON aren’t available until Monterey. So here’s the ASLS based Extension Attribute a couple ways: getSupportedMacOSVersions-ASLS-EA.sh and getSupportedMacOSVersions-ASLS-legacy-EA.sh both get the job done.

Same. Same.

Bonus Methods for Determining Board ID and Device ID

The ASLS method requires either the Board ID or the Device ID and in a way that worked across all macOS versions and hardware architectures. I’ve updated my gist for that (although gists are a worse junk drawer than a bunch of scripts in a repo if only because it’s hard to get an overall listing) and here’s a few callouts for what I came up with

#DeviceID - ARM, UNIVERSAL - uses xmllint --xpath
myDeviceID=$(ioreg -arc IOPlatformExpertDevice -d 1 | plutil -extract 0.IORegistryEntryName xml1 -o - - | xmllint --xpath '/plist/string/text()' - 2>/dev/null)
#DeviceID - ARM, macOS 12+ only, uses plutil raw output
myDeviceID=$(ioreg -arc IOPlatformExpertDevice -d 1 | plutil -extract 0.IORegistryEntryName raw -o - -)
#NOTE: Different output depending on platform! 
# ARM gets the Device ID - J314cAP
# Intel gets the Model ID - MacBookPro14,3

#Board ID - Intel ONLY, Mac-551B86E5744E2388
#Intel, UNIVERSAL - uses xmllint --xpath
myBoardID=$(ioreg -arc IOPlatformExpertDevice -d 1 | plutil -extract 0.board-id xml1 -o - - | xmllint --xpath '/plist/data/text()' - | base64 -D)
#Intel, macOS 12+ only - uses plutil raw output 
myBoardID=$(ioreg -arc IOPlatformExpertDevice -d 1 | plutil -extract 0.board-id raw -o - - | base64 -D)

Wrapping Up

This was all really an excuse to play around with the Apple Software Lookup Service JSON file, what can I say! And not just to plug jpt either! It was fun to use the new plutil raw type too (is fun the right word?) and “live off the land”. Be aware that the newest macOS version only appears once it’s publicly released, so keep that in mind when scoping. You can still keep scoping in Jamf via Model Identifier Regex or by my older extension attribute just keep in mind you’ll need to update them yearly. Whereas, these newer EAs based on either softwareupate (getSupportedMacOSVersions-SWU-EA.sh) or ASLS (getSupportedMacOSVersions-ASLS-EA.sh, getSupportedMacOSVersions-ASLS-legacy-EA.sh) should take care of themselves into the future.

Determining iCloud Drive and Desktop and Documents Sync Status in macOS

I’m on a roll with iCloud stuff. In this post I’d like to show you how you can determine if either iCloud Drive is enabled along with the “Desktop and Documents Folders” sync feature. While you can use MDM to turn these off, perhaps you like to know who you’d affect first! Perhaps the folks in your Enterprise currently using these features are in the C-Suite? I’m sure they’d appreciate a heads up before all their iCloud docs get removed from their Macs (when MDM disallowance takes affect it is swift and unforgiving).

iCloud Drive Status

When it comes to using on-disk artifacts to figure out the state of macOS I like to use the analogy of reading tea leaves. Usually it’s pretty straightforward but every now and then there’s something inscrutable and you have to take your best guess. For example iCloud Drive status is stored in your home folder at ~/Library/Preferences/MobileMeAccounts.plist but yet the Accounts key is an array. The question is how and why you could even have more than one iCloud account signed-in?! Perhaps a reader will tell me when and how you would ever have more than one? For now it seems inexplicable.

Update: It seems quite obvious now but as many folks did point out, of course you can add another iCloud account to Internet Accounts and it can sync Mail, Contacts, Calendars, Reminders, and Notes, just not iCloud Drive. Only one iCloud Drive user per user on a Mac. While I do have another AppleID I only use it for Media and Purchases and none of the other iCloud services. The code below stays the same as it is looking at all array entries. Aside: Boy, do I wish I could merge or transfer my old iTunes purchasing/media Apple ID with my main iCloud Apple ID! <weakly shakes fist at faceless Apple bureaucracy that for some reason hasn't solved the problem of merging Apple IDs>

Regardless of that mystery jpt can use the JSONPath query language to get us an answer in iCloudDrive_func.sh and the minified iCloudDrive_func.min.sh. Below is an edited excerpt:

#!/bin/bash
#Joel Bruner - iCloudDrive.func.sh - gets the iCloud Drive status for a console user

#############
# FUNCTIONS #
#############

function iCloudDrive()(

	#for brevity pretend we've pasted in the minified jpt function:
	#https://github.com/brunerd/jpt/blob/main/sources/jpt.min
	#for the full function see https://github.com/brunerd/macAdminTools/tree/main/Scripts

	consoleUser=$(stat -f %Su /dev/console)

	#if root grab the last console user
	if [ "${consoleUser}" = "root" ]; then
		consoleUser=$(/usr/bin/last -1 -t console | awk '{print $1}')
	fi

	userPref_json=$(sudo -u $consoleUser defaults export MobileMeAccounts - | plutil -convert json - -o -)

	#pref domain not found an empty object is returned
	if [ "${userPref_json}" = "{}" ]; then
		return 1
	else
		#returns the number paths that match
		matchingPathCount=$(jpt -r '$.Accounts[*].Services[?(@.Name == "MOBILE_DOCUMENTS" && @.Enabled == true)]' <<< "${userPref_json}" 2>/dev/null | wc -l | tr -d "[[:space:]]")
	
		if [ ${matchingPathCount:=0} -eq 0 ]; then
			return 1
		else
			return 0
		fi
	fi
)
########
# MAIN #
########

#example function usage, if leverages the return values
if iCloudDrive; then
	echo "iCloud Drive is ON"
	exit 0
else
	echo "iCloud Drive is OFF"
	exit 1
fi

The magic happens once we’ve gotten the JSON version of MobileMeAccount.plist I use jpt to see if there are any objects within the Accounts array with Services that have both a Name that matches MOBILE_DOCUMENTS and have an Enabled key that is set to true, the -r option on jpt tells it to output the the JSON Pointer path(s) the query matches. I could have used the -j option to output JSONPath(s) but either way a line is a line and that’s all we need. Altogether it looks like this: jpt -r '$.Accounts[*].Services[?(@.Name == "MOBILE_DOCUMENTS" && @.Enabled == true)]

Again because Accounts is an Array we have it look at all of them with $.Accounts[*]and in the off chance we get more than one we simply say if the number of matches is greater than zero then it’s on. This works very well in practice. This function could best be used as a JAMF Extension Attribute. I’ll leave that as a copy/paste exercise for the reader. Add it to your Jamf Pro EAs, let sit for 24-48 hours and check for results! ⏲ And while some of you might balk at a 73k Extensions Attribute, the execution time is on average a speedy .15s!

#!/bin/bash
#a pretend iCloudDrive Jamf EA 
#pretend we've pasted in the function iCloudDrive() above

if iCloudDrive; then
	result="ON"
else
	result="OFF"
fi

echo "<result>${result}</result>"

iCloud Drive “Desktop and Documents” status

Thankfully, slightly easier to determine yet devilishly subtle to discover, is determining the status of the “Desktop and Documents” sync feature of iCloud Drive. After searching in vain for plist artifacts, I discovered the clue is in the extended attributes of your Desktop (and/or Documents) folder! You can find the scripts at my GitHub here iCloudDriveDesktopSync_func.sh and the minified version iCloudDriveDesktopSync_func min.sh

#!/bin/bash
#Joel Bruner - iCloudDriveDesktopSync - gets the iCloud Drive Desktop and Document Sync Status for the console user

#############
# FUNCTIONS #
#############

#must be run as root
function iCloudDriveDesktopSync()(
	consoleUser=$(stat -f %Su /dev/console)

	#if root (loginwindow) grab the last console user
	if [ "${consoleUser}" = "root" ]; then
		consoleUser=$(/usr/bin/last -1 -t console | awk '{print $1}')
	fi

	#if this xattr exists then sync is turned on
	xattr_desktop=$(sudo -u $consoleUser /bin/sh -c 'xattr -p com.apple.icloud.desktop ~/Desktop 2>/dev/null')

	if [ -z "${xattr_desktop}" ]; then
		return 1
	else
		return 0
	fi
)

#example function usage, if leverages the return values
if iCloudDriveDesktopSync; then
	echo "iCloud Drive Desktop and Documents Sync is ON"
	exit 0
else
	echo "iCloud Drive Desktop and Documents Sync is OFF"
	exit 1
fi

The operation is pretty simple, it finds a console user or last user, then runs the xattr -p command as that user (anticipating this being run as root by Jamf) to see if the com.apple.icloud.desktop extended attribute exists on their ~/Desktop. In testing you’ll find if you toggle the “Desktop and Documents” checkbox in the iCloud Drive options, it will apply this to both of those folders almost immediately without fail. The function can be used in a Jamf Extension Attribute in the same way the iCloudDrive was above. Some assembly required. πŸ’ͺ

So, there you go! Another couple functions to read the stateful tea leaves of iCloud Drive settings. Very useful if you are about to disallow iCloud Drive and/or Desktop and Document sync via MDM but need to know who you are going to affect and let them know beforehand. Because I still stand by this sage advice: Don’t Be A Jerk. Thanks for reading you can find these script and more at my GitHub repo. Thanks for reading!

Determining “iCloud Private Relay” and “Limit IP tracking” status in macOS

If you are a Mac admin you might have noticed some Apple plists are more complex than others these days. As evidenced in my post Don’t Be a Jerk, getting the status of Do Not Disturb in Big Sur was a multi-step exercise. Check out this modified code excerpt from doNotDisturb‘s Big Sur handling

#!/bin/sh

#Big Sur DnD status, returns "true" or [blank] (to be run as console user)
dndStatus="$(/usr/libexec/PlistBuddy -c "print :userPref:enabled" /dev/stdin 2>/dev/null <<< "$(plutil -extract dnd_prefs xml1 -o - /dev/stdin <<< "$(defaults export com.apple.ncprefs.plist -)" | xmllint --xpath "string(//data)" - | base64 --decode | plutil -convert xml1 - -o -)")"

#if we have ANYTHING it is ON (return 0) otherwise fail (return 1)
[ -n "${dndStatus}" ] && { echo ON; exit 0; } || { echo OFF; exit 1; }

Why is it so complex? Well because with the user preference domain of com.apple.ncprefs there is a base64 encoded plist embedded in the dnd_prefs key and requires some massaging to get it to a state where PlistBuddy can read and then extract the status from the :userPref:enabled key. See it’s just that easy! πŸ˜…

In macOS Monterey and higher Apple is now using JSON for Focus status which is great because it can can be parsed much easier (perhaps using my ljt or jpt tools) but sometimes not even that is needed! Sometimes file presence or awking is sufficient to get a reliable result too. See the evolution of doNotDisturb.sh for macOS for the myriad techniques one can use.

Now, here we are at Monterey on the verge of Ventura and not all areas of macOS have seen the JSON light and are still using nested base64-encoded Plist blobs to store the states of new features like iCloud Private Relay but luckily not the per interface setting of Limit IP Tracking.

iCloud Private Relay Status

Now why would you want to know these statuses? Perhaps you have a VPN product that doesn’t work when Private Relay is turned on or if Limit IP Tracking is turned on for a particular interface and you need to alert the user. Additionally maybe there’s an issue if your VPN browser handshake fails when Safari is used, if so see: Determining URL scheme handlers in macOS. Back to the task at hand though let’s turn some prefs inside out and look at iCloud Relay status with iCloudPrivateRelayStatus.sh

#!/bin/bash
: <<-LICENSE_BLOCK
iCloud Private Relay Status Checker - (https://github.com/brunerd)
Copyright (c) 2022 Joel Bruner (https://github.com/brunerd)
Licensed under the 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.
LICENSE_BLOCK

#############
# FUNCTIONS #
#############

#include this self-contained function in your script
function iCloudPrivateRelay(){

	#only for Moneterey and up, 11 and under need not apply
	[ "$(sw_vers -productVersion | cut -d. -f1)" -le 11 ] && return 1
	
	#parent pref domain
	domain="com.apple.networkserviceproxy"
	#key that contains base64 encoded Plist within parent domain
	key="NSPServiceStatusManagerInfo"

	#child key within base64 embedded plist
	childKey="PrivacyProxyServiceStatus"

	#get the top level data from the main domain
	parentData=$(launchctl asuser $(stat -f %u /dev/console) sudo -u $(stat -f %Su /dev/console) defaults export ${domain} -)

	#if domain does not exist, fail
	[ -z "${parentData}" ] && return 1

	#export the base64 encoded data within the key as PlistBuddy CF style for grepping (it resists JSON extraction)
	childData=$(/usr/libexec/PlistBuddy -c "print :" /dev/stdin 2>/dev/null <<< $(plutil -extract "${key}" xml1 -o - /dev/stdin <<< "${parentData}" | xmllint --xpath "string(//data)" - | base64 --decode | plutil -convert xml1 - -o -))

	#if child key does not exist, fail
	[ -z "${childData}" ] && return 1

	#match the status string, then get the value using awk (quicker than complex walk, this is sufficient), sometimes written in multiple places
	keyStatusCF=$(awk -F '= ' '/'${childKey}' =/{print $2}' <<< "${childData}" | uniq)
	
	#if we have differing results that don't uniq down to one line, throw an error
	[ $(wc -l <<< "${keyStatusCF}") -gt 1 ] && return 2
	
	#if true/1 it is on, 0/off (value is integer btw not boolean)
	[ "${keyStatusCF}" = "1" ] && return 0 || return 1
}

########
# MAIN #
########

#example - one line calling with && and ||
#iCloudPrivateRelay && echo "iCloud Private Relay is: ON" || echo "iCloud Private Relay is: OFF"

#example - multi-line if/else calling
if iCloudPrivateRelay; then
	echo "iCloud Private Relay is: ON"
	exit 0
else
	echo "iCloud Private Relay is: OFF"
	exit 1
fi

Even though we work really hard to get the plist data extracted we don’t need to walk the entire XML document (you will find PlistBuddy balks at exporting JSON for a number of reasons). Instead we look for the presence of the key name PrivacyProxyServiceStatus and that is sufficient to reliably detect the state. I have 2 versions in my GitHub iCloudPrivateRelayStatus.sh and the minified one line version iCloudPrivateRelayStatus.min.sh

Limit IP Tracking Status

Limit IP Tracking, is a per-interface setting that is on by default, however has no effect unless Private Relay is enabled. For this we will use ljt my Little JSON Tool to retrieve the value from the massaged JSON conversion of com.apple.wifi.known-networks.plist if on WiFi or if on Ethernet .../SystemConfiguration/preferences.plist

#!/bin/bash
: <<-LICENSE_BLOCK
Limit IP Tracking Status Checker - (https://github.com/brunerd)
Copyright (c) 2022 Joel Bruner (https://github.com/brunerd)
Licensed under the 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.
LICENSE_BLOCK

#############
# FUNCTIONS #
#############

#include this self-contained function in your script
function limitIPTracking()(

	#only for Moneterey and up, 11 and under need not apply
	[ "$(sw_vers -productVersion | cut -d. -f1)" -le 11 ] && return 1

	#Little JSON Tool (ljt) - https://github.com/brunerd/ljt - MIT License
	function ljt () ( #v1.0.8 ljt [query] [file]
	{ set +x; } &> /dev/null; read -r -d '' JSCode <<-'EOT'
	try{var query=decodeURIComponent(escape(arguments[0]));var file=decodeURIComponent(escape(arguments[1]));if(query===".")query="";else if(query[0]==="."&&query[1]==="[")query="$"+query.slice(1);if(query[0]==="/"||query===""){if(/~[^0-1]/g.test(query+" "))throw new SyntaxError("JSON Pointer allows ~0 and ~1 only: "+query);query=query.split("/").slice(1).map(function(f){return"["+JSON.stringify(f.replace(/~1/g,"/").replace(/~0/g,"~"))+"]"}).join("")}else if(query[0]==="$"||query[0]==="."&&query[1]!=="."||query[0]==="["){if(/[^A-Za-z_$\d\.\[\]'"]/.test(query.split("").reverse().join("").replace(/(["'])(.*?)\1(?!\\)/g,"")))throw new Error("Invalid path: "+query);}else query=query.replace("\\.","\udead").split(".").map(function(f){return"["+JSON.stringify(f.replace("\udead","."))+"]"}).join("");if(query[0]==="$")query=query.slice(1);var data=JSON.parse(readFile(file));try{var result=eval("(data)"+query)}catch(e){}}catch(e){printErr(e);quit()}if(result!==undefined)result!==null&&result.constructor===String?print(result):print(JSON.stringify(result,null,2));else printErr("Path not found.")
	EOT
	queryArg="${1}"; fileArg="${2}";jsc=$(find "/System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/" -name 'jsc');[ -z "${jsc}" ] && jsc=$(which jsc);[ -f "${queryArg}" -a -z "${fileArg}" ] && fileArg="${queryArg}" && unset queryArg;if [ -f "${fileArg:=/dev/stdin}" ]; then { errOut=$( { { "${jsc}" -e "${JSCode}" -- "${queryArg}" "${fileArg}"; } 1>&3 ; } 2>&1); } 3>&1;else [ -t '0' ] && echo -e "ljt (v1.0.8) - Little JSON Tool (https://github.com/brunerd/ljt)\nUsage: ljt [query] [filepath]\n  [query] is optional and can be JSON Pointer, canonical JSONPath (with or without leading $), or plutil-style keypath\n  [filepath] is optional, input can also be via file redirection, piped input, here doc, or here strings" >/dev/stderr && exit 0; { errOut=$( { { "${jsc}" -e "${JSCode}" -- "${queryArg}" "/dev/stdin" <<< "$(cat)"; } 1>&3 ; } 2>&1); } 3>&1; fi;if [ -n "${errOut}" ]; then /bin/echo "$errOut" >&2; return 1; fi
	)
		
	#test interface specified or fallback to default interface
	interfaceID=${1:-$(route get 0.0.0.0 2>/dev/null | awk '/interface: / {print $2}')}

	#WIFI: key: PrivacyProxyEnabled, file: /Library/Preferences/com.apple.wifi.known-networks.plist
	#if no error getting WiFi SSID, then we are WiFi
	if networksetup -getairportnetwork "${interfaceID}" &>/dev/null && wifiSSID=$(awk -F ': ' '{print $2}' <<< "$(networksetup -getairportnetwork "${interfaceID}" 2>/dev/null)"); then

		#key name inside plist
		keyName="wifi.network.ssid.${wifiSSID}"

		#oddly this file is only read-able by root
		if [ ! -r /Library/Preferences/com.apple.wifi.known-networks.plist ]; then
			echo "Insufficient preferences to determine WiFi state, run as root" >/dev/stderr
			exit 1
		fi

		#get JSON version, so much easier to get around, convert date and data types to strings
		wifiKnownNetworks_JSON=$(defaults export /Library/Preferences/com.apple.wifi.known-networks.plist - | sed -e 's,<date>,<string>,g' -e 's,</date>,</string>,g' -e 's,<data>,<string>,g' -e 's,</data>,</string>,g' | plutil -convert json -o - -)
		
		#if there is NO entry, then it is active, it is opt-out designed
		PrivacyProxyEnabled=$(ljt "/wifi.network.ssid.${wifiSSID}/PrivacyProxyEnabled" 2>/dev/null <<< "${wifiKnownNetworks_JSON}")
		
		if [ "${PrivacyProxyEnabled}" = "false" ]; then
			return 1
		fi
	#ETHERNET: key: DisablePrivateRelay, file: /Library/Preferences/SystemConfiguration/preferences.plist, 
	else
		#get JSON, easily converts with not data or date types within
		systemConfigPrefsJSON=$(plutil -convert json -o - /Library/Preferences/SystemConfiguration/preferences.plist)
		#get current set UUID
		currentSet=$(echo "${systemConfigPrefsJSON}" | ljt /CurrentSet 2>/dev/null)
		#get value for current default interface of current Location set
		DisablePrivateRelay=$(ljt "${currentSet}"/Network/Interface/${interfaceID}/DisablePrivateRelay  2>/dev/null <<< "${systemConfigPrefsJSON}")

		#if it is TRUE we are Disabled, then we are NOT ON, return fail code
		if [ "${DisablePrivateRelay}" = "1" ]; then
			return 1
		fi
	fi
)

########
# MAIN #
########

if [ ! -r /Library/Preferences/com.apple.wifi.known-networks.plist ]; then
	echo "Insufficient preferences to determine WiFi state, run as root" >/dev/stderr
	exit 1
fi

#use default interface if nothing is specified for argument $1
interface=${1:-$(route get 0.0.0.0 2>/dev/null | awk '/interface: / {print $2}')}

#returns 0 if ON and 1 if OFF, you may supply an interface or it will fall back to the default
limitIPTracking "${interface}" && echo "Limit IP tracking is ON for: ${interface}" || echo "Limit IP tracking is OFF for: ${interface}"

You can specify an interface or it will use the default interface (there’s a good one-liner to use route get 0.0.0.0 to figure that out). plutil is not initially used to convert com.apple.wifi.known-networks.plist to JSON as it will fail due to XML/plist data types like <date> and <data> that do not have JSON equivalents. First we use sed to change them to <string> types then plutil can convert to JSON. After that it’s a cake walk for ljt to get the value and report back. If the interface is not WiFi then /Library/Preferences/SystemConfiguration/preferences.plist has none of those conversion issues. Check out limitIPTrackingStatus.sh and the minified limitIPTrackingStatus.min.sh at my GitHub.

Thanks for reading! P.S. I won’t be at JNUC 2022, better luck next year!

jpt + jamf uapi = backupJamf-scripts

Jamf UAPI: JSON Only

I am a creature of habit, no doubt, however sometimes you must get out of your comfort zone. The Jamf Universal API (UAPI) is one such case, it is JSON only and not XM. Those tried and true xpath snippets will no longer work with JSON, in fact what tool do you use with JSON? macOS really doesn’t have a good built-in JSON tool and if your scripts are client side do you really want to have jq as a dependency? Good thing I wrote a JSON parser you can embed in your scripts this summer! In fact, when I finished writing my JSON power tool jpt, I needed to find some practical examples to demonstrate its utility. Looking at the UAPI it’s clear some parts are still a work-in-progress, however the endpoint for scripts is actually really good. It gives you everything in one go almost like a database query. That should made backing up scripts a breeze!

backupJamf-scripts. boom.

If you’ve used the Classic API from Jamf, it is a 1:1 ratio of scripts to API calls: 2 scripts? 2 API calls via curl. 200 scripts? 200 API calls via curl. The new Universal API reduces that down to 1 call to get everything (plus one call to get the token), it’s super fast and I love it. Check out backupJamf-scripts.command in my newly minted jamfTools repo on GitHub for a working demostration of both the Jamf UAPI and jpt’s in-script JSON handling. I hope you like it!

jpt + jamf uapi = scripts downloading scripts

jpt: jamf examples

jpt has some practical applications for the Jamf admin

Sanitizing Jamf Reports

Let’s say you’ve exported an Advanced Search, it’s got some interesting data you’d like to share, however there is personal data in it. Rather than re-running the report, why not blank out or remove those fields?

Here’s a sample file: advancedcomputersearch-2-raw.json

This an excerpt from one of the computer record:

{
"name": "Deathquark",
"udid": "2ca8977b-05a1-4cf0-9e06-24c4aa8115bc",
"Managed": "Managed",
"id": 2,
"Computer_Name": "Deathquark",
"Last_Inventory_Update": "2020-11-04 22:01:09",
"Total_Number_of_Cores": "8",
"Username": "Professor Frink",
"FileVault_2_Status": "Encrypted",
"JSS_Computer_ID": "2",
"Number_of_Available_Updates": "1",
"Model_Identifier": "MacBookPro16,1",
"Operating_System": "Mac OS X 10.15.7",
"Model": "MacBook Pro (16-inch, 2019)",
"MAC_Address": "12:34:56:78:9A:BC",
"Serial_Number": "C02K2ND8CMF1",
"Email_Address": "frink@hoyvin-glavin.com",
"IP_Address": "10.0.1.42",
"FileVault_Status": "1/1",
"Processor_Type": "Intel Core i9",
"Processor_Speed_MHz": "2457",
"Total_RAM_MB": "65536"
}

Now let’s say the privacy standards for this place is GDPR on steroids and all personally identifiable information must be removed, including serials, IPs, UUIDs, almost everything (but you don’t want to run the report again because it took ages to get the output)!

Here’s what that command would look like: jpt -o replace -v '"REDACTED"' -p '$["advanced_computer_search"]["computers"][*]["name","udid","Username","Computer_Name","MAC_Address","Serial_Number","Email_Address","IP_Address"]' ./advancedcomputersearch-2-raw.json

Here’s what that same computer looks like in the resulting output (as well as all others in the document):

  {
    "name": "REDACTED",
    "udid": "REDACTED",
    "Managed": "Managed",
    "id": 2,
    "Computer_Name": "REDACTED",
    "Architecture_Type": "i386",
    "Make": "Apple",
    "Service_Pack": "",
    "Last_Inventory_Update": "2020-11-04 22:01:09",
    "Active_Directory_Status": "Not Bound",
    "Total_Number_of_Cores": "8",
    "Username": "REDACTED",
    "FileVault_2_Status": "Encrypted",
    "JSS_Computer_ID": "254",
    "Number_of_Available_Updates": "1",
    "Model_Identifier": "MacBookPro16,1",
    "Operating_System": "Mac OS X 10.15.7",
    "Model": "MacBook Pro (16-inch, 2019)",
    "MAC_Address": "REDACTED",
    "Serial_Number": "REDACTED",
    "Email_Address": "REDACTED",
    "IP_Address": "REDACTED",
    "FileVault_Status": "1/1",
    "Processor_Type": "Intel Core i9",
    "Processor_Speed_MHz": "2457",
    "Total_RAM_MB": "65536"
  }

What we did was use the -o replace operation with a -v <value> of the JSON string "REDACTED" to all the paths matched by the JSONPath union expression (the comma separated property names in brackets) of the -p option. JSON Pointer can only act on one value at a time, this is where JSONPath can save you time and really shines.

jpt is fast because WebKit’s JavascriptCore engine is fast, for instance there is a larger version of that search that has 15,999 computers, it took only 11 seconds to redact every record.

jpt -l $.advanced_computer_search.computers ./advancedcomputersearch.json
15999

time jpt -o replace -v '"REDACTED"' -p '$.advanced_computer_search.computers[*]["name","udid","Username","Computer_Name","MAC_Address","Serial_Number","Email_Address","IP_Address"]' ./advancedcomputersearch.json > /dev/null 

11.14s user 0.35s system 104% cpu 11.030 total

Put Smart Computer Groups on a diet

When you download Smart Groups via the API, you will also get an array of all the computers objects that match at that moment in time. If you just want to back up the logic or upload to another system, you don’t want all those computers in there.

Sample file: smartgroup-1-raw.json

{
  "computer_group": {
    "id": 12,
    "name": "All 10.15.x Macs",
    "is_smart": true,
    "site": {
      "id": -1,
      "name": "None"
    },
    "criteria": [
      {
        "name": "Operating System Version",
        "priority": 0,
        "and_or": "and",
        "search_type": "like",
        "value": "10.15",
        "opening_paren": false,
        "closing_paren": false
      }
    ],
    "computers": [
      {
        "id": 1,
        "name": "mac1",
        "mac_address": "12:34:56:78:9A:BC",
        "alt_mac_address": "12:34:56:78:9A:BD",
        "serial_number": "Z18D132XJTQD"
      },
      {
        "id": 2,
        "name": "mac2",
        "mac_address": "12:34:56:78:9A:BE",
        "alt_mac_address": "12:34:56:78:9A:BF",
        "serial_number": "Z39VM86X01MZ"
      }
    ]
  }
}

Lets’ remove those computers with jpt and the JSON Patch remove operartion:
jpt -o remove -p '$.computer_group.computers' ./smartgroup-1-raw.json

Since the target is a single property name, JSON Pointer can also be used:
jpt -o remove -p /computer_group/computers ./smartgroup-1-raw.json

{
  "computer_group": {
    "id": 12,
    "name": "All 10.15.x Macs",
    "is_smart": true,
    "site": {
      "id": -1,
      "name": "None"
    },
    "criteria": [
      {
        "name": "Operating System Version",
        "priority": 0,
        "and_or": "and",
        "search_type": "like",
        "value": "10.15",
        "opening_paren": false,
        "closing_paren": false
      }
    ]
  }
}

Further Uses

Using only JSON Patch replace and remove operations this JSON could have its id removed to prep it for an API POST on another JSS to create a new group or the name and value could be modified in a looping script to create multiple JSON files for every macOS version. The jpt is flexible enough to handle most anything you throw at it, whether you are using the standalone version or have embedded it in your scripts.

Stop by the jpt GitHub page to get your copy

secret origins: the jpt

On building a JSON tool for macOS without using Python, perl, or Ruby.

In my work as a Macintosh engineer and administrator I’ve noticed macOS has lacked a bundled tool for working with JSON at the command line. Where XML has its xpath, if your shell script needs some JSON chops, it’ll require an external binary like jq or something else scripted in Python, Ruby or perl using their JSON modules. The problem is, those runtimes have been slated for removal from a future macOS. So I took that as challenge to devise a method to query and modify JSON data within shell scripts, that didn’t use one of those deprecated scripting runtimes and didn’t require an external binary dependency either. Could I achieve robust and native JSON parsing on a Mac by simply “living off the land”?

Why not just re-install the runtimes when Apple deprecates them?

“Why limit yourself like this? Just re-install the runtimes and move on”, you may ask. Well, I’d like to think that limitations can inspire creativity but we should also consider there may be some other reasons why Apple is discontinuing the inclusion of those runtimes. Some may say, “Apple Silicon + macOS 11.0 is the perfect time for them to clean house”, to which I’d have to agree that’s a very good reason and likely a factor. Others could say they are looking to tighten the screws to keep out unsigned code: maybe, they do like to glue things shut! But really, I think it’s more akin to the web-plugins of yore like Java and Flash. Apple does not want to be the conduit for deploying 3rd party party runtimes which increase the attack surface of macOS. This seems like the most reasonable of the explanations. So, if you accept that Apple is attempting to reduce attack surface, why increase it by re-installing Python, Ruby, or perl, just so a transient script (like a Jamf Extension Attribute) can parse a JSON file? My answer to that, is you don’t. You play the hand you’re dealt. Game on!

Looking for truffles (in a very small back yard)

Despite having another project (shui) that can output and invoke Applescript from within a shell scripts for generating user interfaces, I definitely knew that Applescript was not the way to go. Apple however, added to the languages Open Scripting Architecture (OSA) supports back in 2014 with OS X Yosemite (10.10), they added Javascript along with a bridge to the Cocoa Objective-C classes and they called it JXA: Javascript for Automation. This seemed like a promising place, so I started playing with osascript and figured out how to load files and read /dev/stdin using JXA, and while looking for an answer for garbled input from stdin I came upon a Japanese blog that mentioned jsc the JavaScriptCore binary which resides in the /System/Library/Frameworks/ JavaScriptCore.framework. Arigato! Pay dirt! πŸ€‘ jsc does exactly what we need it to do: It can interpret Javascript passed as an argument, can access the filesystem and read from /dev/stdin, and best of all is in non-Private System level Framework that exists all the way back to OS X 10.4! Just the kind of foundation on which to build the tool.

Homesteading jsc

The existence of jsc goes back all the way to OS X Tiger and it’s functionality has evolved over the years. In order to have a consistent experience in jpt from macOS 10.4 – 11.x+ a few polyfills had to be employed for missing functions, along with a few other workarounds regarding file loading, printing and exit codes (or lack thereof). Once those were addressed the jsc proved to be a highly optimized Javascript environment that’s blazingly fast. It spans 13 macOS releases and is even present in many Linux distros out-of-the-box (Ubuntu and CentOS) and can even be run on Windows when the Linux Subsytem is installed.

With the host environment sorted, I began working with the original JSONPath code by Stefan Goessner as the query language. I didn’t know about JSON Pointer yet so this strange beast was all I knew! It worked out really. I went full throttle into developing the “swiss army knife” of JSON tools. I really leaned into the Second System Effect, as described in the Mythical Man-month, it’s when you put every doodad, gizmo and doo-hicky in your 2nd product (my first simple JSON pretty-printer built in JS on jsc). Eventually though, after I reached a feature plateau, I came back around to the address the quirks of the original JSONPath code. I ended up rewriting signifigant chunks of JSONPath and released it as it’s own project: brunerd JSONPath. But I digress, let’s get back to the JSON Power Tool.

jpt: powers and abilities

At it’s most basic, jpt will format or “pretty print” any JSON given. jpt can also handle data retrieval from JSON document using either JSONPath or JSON Pointer syntax. JSONPath, while not a standard, is a highly expressive query language akin to XPath for XML, with poweful features like recursive search, filter expressions, slices, and unions. JSON Pointer on the other hand is narrower in focus, succint and easily expressed, and standardized but it does not offer any of the interogative features that I feel make JSONPath so intriguing. Finally, jpt can also modify values in a JSON document using standardized JSON Patch operations like: add, remove, replace, copy, move, and test, as well as the also standardized JSON Merge Patch operation. Altogether, the jpt can format, retrieve and alter JSON documents using only a bit of outer shell script plus a lot more Javascript on any macOS since 10.4! πŸ˜…

Where can I get the jpt?

Stop by the project’s GitHub page at: https://github.com/brunerd/jpt

There you will find the full source and also a minified version of the jpt for inclusion within your shell scripts. Since it’s never compiled you can always peer inside and learn from it, customize it, modify it, or just tinker around with it (usually the best teacher).

Future Plans

There will undoubtedly be continued work on the jpt. Surely there are less than optimal routines, un-idiomatic idioms, edge cases not found, and features yet to be realized. But as far as the core functionality goes though, it’s fairly feature complete in my opinion. Considering that one of my top 10 StrengthsFinder qualities is “Maximizer”, the odds are pretty good, I’ll keep honing the jpt‘s utility, size (smaller), and sharing more articles with examples on the kinds of queries and data alteration operations the jpt can so perform. Stay tuned!

New Projects: jpt and shui, Now Available

Between March and October 2020 I had some great ideas for command line Mac utilities the MacAdmin could apprecite and I had the time to devote to their realization. I’m excited to present these two open source projects, available on GitHub: jpt and shui. I hope they can add richness to your shell scripts’ presentation and capabilities without requiring additional external dependancies.

jpt – the “JSON Power Tool” is a Javascript and shell script polyglot that leverages jsc, the JavascriptCore binary that is standard on every Mac since 10.4 and since the jpt is purposefully written in ES5 to maintain maximum compatibility, why yes, this tool does run on both PPC and Intel Macs all the way back to OS X Tiger and then all the way forward to the latest 11.0 macOS Big Sur! Many Linux distros like CentOS and Ubuntu come with jsc pre-installed also, even Windows with the Linux Subsystem installed can run jsc and therefore can run the jpt!

What you can do with the jpt? Query JSON documents using either the simple yet expressive JSONPath syntax or the singular and precise JSON Pointer (RFC6901) syntax. The output mode is JSON but additional creative output modes can render JSONPaths, JSON Pointer paths, or even just the property names with their “constructor” types (try -KC with -J or -R) Textual output can be encoded in a variety of formats (hex/octal/URI encoding, Unicode code points, etc…), data can be modified using both JSON Patch (RFC6902) operations (add, replace, remove, copy, move, test) and also JSON Merge Patch (RFC7386) operations. JSON can be worked with in new ways, try -L for “JSONPath Object Literal” output to see what I mean. Or you simply feed jsc a file to pretty-print (stringify) to /dev/stdout. I’ll be writing more about this one for sure.
Github project page: jpt
Tagged blog posts: scripting/jpt

shui – first-class Applescript dialog boxes in your shell scripts without needing to remember esoteric Applescript phrasings! If you think it’s odd for code to have possessive nouns and are more comfortable in shell, you’re not alone. shui can be embedded in either bash or zsh scripts but it can also output Applescript if you really want to know how the sausage is made or want to embed in your script without shui. Hopefully shui will let you forget those awkward Applescript phrasing and focus on your shell script’s features and functionality. It uses osascript to execute the Applescript and launchctl to invoke osascript in the correct user context so user keyboard layouts are respected (vs. root runs). Check out the project page for demo videos and then give shui a try.
Project page: shui
Tagged blog posts: scripting/shui