Having pained over this for a couple of days, I thought I'd share what have found out about this...
User data is stored in the "Open Directory", like Apple's version of OpenLDAP or ActiveDirectory. Old documentation here. There are two user data items containing images:
- The original picture data (goes back to early OSX versions) is called
Picture
and contains a path to an image on the file system. It does not have to be JPEG but the path is limited to 255 UTF-8 characters.
- The
JPEGPhoto
, is a JPEG-formatted image that is embedded into the user record (in contrast to Picture which points to a file on the file system). It seems to have been introduced around OSX 10.5/6.
- If a user has both
JPEGPhoto
and Picture
then the Picture
is ignored (at least by the login and user preferences screens).
- The
JPEGPhoto
is stored as base64-encoded JPEG image data.
You can use dscl
(directory service command line) to query the directory and extract image data. If you do this then the image data is presented as hexadecimal data (i.e. the base64 is decoded into hex):
dscl . read /Users/alice JPEGPhoto
You can decode the hex data into an image file:
dscl . read /Users/alice JPEGPhoto | tail -1 | xxd -r -p > photo.jpg
(a quick way to view an image from the macos command line is qlmanage -p photo.jpg
)
Deleting images is also done using dscl
:
dscl . delete /Users/alice JPEGPhoto
dscl . delete /Users/alice Picture
You can also use dscl
to load data:
dscl . read /Users/alice Picture /path/to/alice.jpg
That's handy for the Picture
but JPEGPhoto
data is unlikely to fit on the command line as an argument. Instead, you can use dsimport
to import JPEGPhoto. This requires that you create an import file as described here which distils to the following pattern seen in other answers, articles and blog posts elsewhere:
0x0A 0x5C 0x3A 0x2C dsRecTypeStandard:Users 2 dsAttrTypeStandard:RecordName externalbinary:dsAttrTypeStandard:JPEGPhoto
alice:/path/to/alice.jpg
This imports the image at the given path into the JPEGPhoto of the user's record, storing it internally as base64-encoded binary data. An alternative way to do this is to pass the encoded image data directly:
0x0A 0x5C 0x3A 0x2C dsRecTypeStandard:Users 2 dsAttrTypeStandard:RecordName base64:dsAttrTypeStandard:JPEGPhoto
alice:<base64 data>
To summarise the import file format, the four hex characters at the beginning specify the data format:
- 0x0A End of record is indicated by a new line
- 0x5C The escape character is defined as \
- 0x3A The field separator is a :
- 0x2C The value separator is a , (comma)
These values, which are the only ones ever seen in examples, define the data format such that each record is a line on its own, terminated by a newline. The record is composed of fields separated by colons and multi-value fields separate values using commas. Should there be a need to escape either of those characters then the escape character is \
.
Following those four hex values is the record type (here we have dsRecTypeStandard:Users
for user records), the number of fields (2) and what they are (dsAttrTypeStandard:RecordName
- which is the username - and dsAttrTypeStandard:JPEGPhoto
). The externalbinary
prefix tells dsimport
how to interpret the field, in this case as the path to an image file (there are a number of these including base64
, utf8
and externalutf8
but documentation about this appears lacking).
Recall that the JPEGPhoto
is stored as base64-encoded data - dsimport
performs any required conversion (in the case of base64
, as used above, there is no conversion).
You import the prepared import file like this:
dsimport dsimport_file /Local/Default M
Again, a little explanation: /Local/Default
is the path of the Open Directory and M
requests that import data is merged where data exists. See the man page for dsimport. You can add --loglevel n
where n
is between 0 and 3 (Command Line documentation, page 118) and view log files written to ~/Library/Logs/ImportExport
.
If replacing an image, it's necessary to delete any existing image first (use dscl
, described above) otherwise the image doesn't update.
So, to summarise, two examples. First passing the image file's path in the import file:
#!/bin/sh
user="$1"
image="$2"
dscl . delete /Users/$user JPEGPhoto
dscl . delete /Users/$user Picture
tmp="$(mktemp)"
printf "0x0A 0x5C 0x3A 0x2C dsRecTypeStandard:Users 2 dsAttrTypeStandard:RecordName externalbinary:dsAttrTypeStandard:JPEGPhoto\n%s:%s" "$user" "$image" > "$tmp"
dsimport "$tmp" /Local/Default M
rm "$tmp"
And, secondly, encoding the image in the import file:
#!/bin/sh
user="$1"
image="$2"
dscl . delete /Users/$user JPEGPhoto
dscl . delete /Users/$user Picture
tmp="$(mktemp)"
printf "0x0A 0x5C 0x3A 0x2C dsRecTypeStandard:Users 2 dsAttrTypeStandard:RecordName base64:dsAttrTypeStandard:JPEGPhoto\n%s:%s" "$user" "$(base64 "$image")" > "$tmp"
dsimport "$tmp" /Local/Default M
rm "$tmp"
Use like this:
$ sudo add-photo username /path/to/photo.jpg
Footnote
I am using this in an Ansible role to update users' avatars on multiple Apple Macs. Ansible is run on a Linux box which is why the embedded image method was interesting to me because I didn't need to write the image to each Mac's disk. In case anyone finds it useful:
- name: User avatars
become: true
ansible.builtin.shell:
cmd: |
tmp="$(mktemp)"
printf "0x0A 0x5C 0x3A 0x2C dsRecTypeStandard:Users 2 dsAttrTypeStandard:RecordName base64:dsAttrTypeStandard:JPEGPhoto\n%s:" "{{item.name}}" > "$tmp"
cat >> "$tmp"
dscl . delete /Users/{{ item.name }} JPEGPhoto
dsimport "$tmp" /Local/Default M
rc=$?
rm "$tmp"
exit $rc
stdin: "{{lookup('file', item.avatar) | b64encode }}"
loop:
"{{ users | selectattr('avatar', 'defined') }}"
The users
passed in is a list having a name
and avatar
value for each.