├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── build.sh ├── customdisplayprofiles ├── requirements-build.txt ├── requirements-dev.txt ├── requirements-pyinstaller.txt ├── requirements.txt └── sample-helper-login-script └── configure_display_profiles.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .venv 3 | build/ 4 | dist-*/ 5 | pkgroot/ 6 | *.pkg 7 | *.pyc 8 | *.spec 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Timothy Sutton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PKGVERSION=1.0.0 2 | PKGNAME=customdisplayprofiles 3 | PKGID=com.github.timsutton.customdisplayprofiles 4 | BUILDPATH=build 5 | INSTALLPATH=/usr/local/bin 6 | MUNKI_REPO_SUBDIR=utilities 7 | 8 | all: clean pkg 9 | clean: 10 | rm -f ${BUILDPATH}/${PKGNAME}-${PKGVERSION}.pkg 11 | rm -rf pkgroot 12 | pkg: 13 | mkdir -p ${BUILDPATH} 14 | mkdir -p pkgroot/${INSTALLPATH} 15 | cp customdisplayprofiles pkgroot/${INSTALLPATH} 16 | pkgbuild --root pkgroot --identifier ${PKGID} --version ${PKGVERSION} ${BUILDPATH}/${PKGNAME}-${PKGVERSION}.pkg 17 | munki: 18 | munkiimport --unattended-install --catalog=testing -n --subdirectory ${MUNKI_REPO_SUBDIR} --developer='Tim Sutton' ${BUILDPATH}/${PKGNAME}-${PKGVERSION}.pkg 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # customdisplayprofiles 2 | 3 | This is a simple command-line Python script that can check, set or unset a custom ColorSync ICC profile for a given display. It uses PyObjC and the most current (as of 2013) ColorSync API to do this. 4 | 5 | ## Installation 6 | 7 | You can either **(1)** download a pre-compiled executable, or **(2)** execute the script directly with a Python runtime you have available. 8 | 9 | ### 1. Pre-compiled executable 10 | 11 | Download the latest version from the [Releases](https://github.com/timsutton/customdisplayprofiles/releases/latest) section, make the file executable and run it. The executable contains the necessary runtime and libraries, bundled via [PyInstaller](https://pyinstaller.org/). 12 | 13 | ### 2. Execute the script directly 14 | 15 | Clone or download this repo (or just the single `customdisplayprofiles` script) and execute it directly. You'll also need to have the [PyObjC PyPi package](https://pypi.org/project/pyobjc/) installed and available in your Python runtime's environment: 16 | 17 | `pip3 install pyobjc` 18 | 19 | 20 | ## Usage 21 | 22 | ### Setting a profile 23 | 24 | Use the `set` action to set a profile (as the user running the command) for the main display. 25 | 26 | `customdisplayprofiles set /path/to/profile.icc` 27 | 28 | Use the `--display` option to configure an alternate display. 29 | 30 | `customdisplayprofiles set --display 2 /path/to/profile.icc` 31 | 32 | If you want to get a list of displays with their associated index: 33 | 34 | `customdisplayprofiles displays` 35 | 36 | 37 | ### Configurable user scope 38 | 39 | The `--user-scope` option allows you to define whether the profile will be applied to the "Current" or "Any" user domain, which may allow you set this preference as a default for all users: 40 | 41 | `customdisplayprofiles set --user-scope any /path/to/profile.icc` 42 | 43 | Specifying `any` here requires root privileges, as it will write these preferences to a system-owned location. 44 | 45 | More information on the user preferences system on OS X can be found [here](https://developer.apple.com/library/mac/#documentation/userexperience/Conceptual/PreferencePanes/Concepts/Managing.html) and [here](http://developer.apple.com/library/ios/#DOCUMENTATION/MacOSX/Conceptual/BPRuntimeConfig/Articles/UserPreferences.html). 46 | 47 | 48 | ### Retrieving the current profile 49 | 50 | The full path to an ICC profile can be printed to stdout: 51 | 52 | `customdisplayprofiles current-path` 53 | 54 | This could be useful if you want to check the current setting using an idempotent login script or a configuration framework like Puppet. 55 | 56 | `current-path` will output nothing if there is no profile currently set for that display. 57 | 58 | 59 | ### Full details 60 | 61 | A more complete dictionary of information can be printed with the `info` action: 62 | 63 |
➜ ./customdisplayprofiles info
 64 | {
 65 |     CustomProfiles =     {
 66 |         1 = "file://localhost/Library/Application%20Support/Adobe/Color/Profiles/SMPTE-C.icc";
 67 |     };
 68 |     DeviceClass = mntr;
 69 |     DeviceDescription = iMac;
 70 |     DeviceHostScope = kCFPreferencesCurrentHost;
 71 |     DeviceID = " 00000610-0000-B005-0000-0000042C0140";
 72 |     DeviceUserScope = kCFPreferencesAnyUser;
 73 |     FactoryProfiles =     {
 74 |         1 =         {
 75 |             DeviceModeDescription = iMac;
 76 |             DeviceProfileURL = "file://localhost/Library/ColorSync/Profiles/Displays/iMac-00000610-0000-B005-0000-0000042C0140.icc";
 77 |         };
 78 |         DeviceDefaultProfileID = 1;
 79 |     };
 80 | }
 81 | 
82 | 83 | 84 | ## Sample wrapper script 85 | 86 | There's a (very simple) example script in the [sample-helper-login-script](https://github.com/timsutton/customdisplayprofiles/blob/master/sample-helper-login-script/configure_display_profiles.sh) folder, which demonstrates how you could wrap this utility in an environment where you don't manage the ICC profiles directly. Someone calibrating a display would only need to drop the profile in a known folder location, indexed by display number, and at login for all users, the desired color profiles are configured for each online display. 87 | 88 | 89 | ## Building a pkg 90 | 91 | You might want to build a pkg to deploy the script to one or more Macs in your environment. To create a pkg so, you can run the `make` command in the repo folder. 92 | The included Makefile will be used to create a package which will install `customdisplayprofiles` in `/usr/local/bin` 93 | If you'd like to install the script at a different path, you can override the default when creating the package with 94 | `make INSTALLPATH=/path/to/installfolder` 95 | 96 | If you're also using munki, there's a `make munki` command to import the package into your munki repository. 97 | 98 | ``` 99 | # first run make to create the pkg 100 | make 101 | 102 | # Then, import the package into munki 103 | make MUNKI_REPO_SUBDIR=util munki 104 | ``` 105 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script will output a single self-contained executable at 4 | # the output specified to pyinstaller's `--distpath` option 5 | 6 | set -eu -o pipefail 7 | 8 | # Pick a Python distribution to use, we'll use Apple's for now 9 | PYTHON_DIST_BIN=/usr/bin/python3 10 | # PYTHON_DIST_BIN=/opt/homebrew/bin/python3 11 | 12 | # clean and setup 13 | command -v deactivate && deactivate 14 | rm -rf .venv dist-* 15 | "${PYTHON_DIST_BIN}" -m venv .venv 16 | source .venv/bin/activate 17 | 18 | # build virtualenv 19 | pip install -U pip 20 | pip install -r requirements-build.txt 21 | 22 | # build it 23 | pyinstaller \ 24 | --clean \ 25 | --log-level DEBUG \ 26 | --distpath dist-onefile \ 27 | --onefile \ 28 | customdisplayprofiles 29 | -------------------------------------------------------------------------------- /customdisplayprofiles: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # customdisplayprofiles 4 | # 5 | # A command-line utility for setting custom ColorSync ICC profiles 6 | # for connected displays. 7 | # 8 | # Copyright 2013 Timothy Sutton 9 | # 10 | # python3 revision for macOS 12.3 written by Jonathon Irons, 3/17/2022 11 | 12 | import os 13 | 14 | # These three modules all come from the PyPI 'pyobjc' package 15 | # This is NOT the Foundation package 16 | import Foundation 17 | # This is NOT the Quartz package 18 | import Quartz 19 | import ColorSync 20 | 21 | import optparse 22 | import sys 23 | 24 | from pprint import pprint 25 | 26 | 27 | def error_exit(msg, code=1): 28 | print(sys.stderr, msg) 29 | sys.exit(code) 30 | 31 | 32 | def verify_profile(profile_url): 33 | # objc still doesn't seem to know about the CFErrorRefs below 34 | profile, create_err = ColorSync.ColorSyncProfileCreateWithURL(profile_url, None) 35 | usable, errors, warnings = ColorSync.ColorSyncProfileVerify(profile, None, None) 36 | 37 | if errors: 38 | print(sys.stderr, "Errors verifying profile:") 39 | print(sys.stderr, errors) 40 | if warnings: 41 | print(sys.stderr, "Warnings verifying profile:") 42 | print(sys.stderr, warnings) 43 | if not usable: 44 | error_exit("Profile could not be verified!") 45 | 46 | 47 | def main(): 48 | possible_actions = { 49 | "current-path": "Print path of current custom profile for device, if any", 50 | "displays": "List online displays and their associated numbers", 51 | "info": "Print out dictionary of device info", 52 | "set": "Set custom profile for device", 53 | "unset": "Unset custom profile for device", 54 | } 55 | possible_user_scopes = ["any", "current"] 56 | default_user_scope = 'current' 57 | 58 | usage = """%s [options] [/path/to/profile] 59 | 60 | Available actions: 61 | """ % (os.path.basename(__file__)) 62 | for k, v in possible_actions.items(): 63 | usage += " {:<20}{}\n".format(k, v) 64 | 65 | o = optparse.OptionParser(usage=usage) 66 | o.add_option('-d', '--display', type='int', default=1, 67 | help="Display number to target the action given. A value of '1' " 68 | "is the default, and means the main display. Second display " 69 | "is '2', etc. Verify the numbers using the 'displays' action.") 70 | o.add_option('-u', '--user-scope', default=default_user_scope, 71 | help="User scope in which to apply the custom profile, when used with the " 72 | "'set' action. Either 'any' or 'current'. 'any' requires " 73 | "root privileges. Defaults to %s." 74 | % default_user_scope) 75 | opts, args = o.parse_args() 76 | 77 | if len(args) == 0: 78 | o.print_help() 79 | sys.exit(1) 80 | 81 | if opts.user_scope: 82 | if opts.user_scope not in possible_user_scopes: 83 | error_exit("--user-scope must be one of: %s" % ", ".join(possible_user_scopes)) 84 | if opts.user_scope == 'any' and os.getuid() != 0: 85 | error_exit("You must have root privileges to modify the any-user scope!") 86 | 87 | if args[0] not in possible_actions.keys(): 88 | o.print_help() 89 | sys.exit(1) 90 | 91 | chosen_action = args[0] 92 | 93 | max_displays = 8 94 | # display list retrieval borrowed from Greg Neagle's mirrortool.py 95 | # https://gist.github.com/gregneagle/5722568 96 | (err, display_ids, 97 | number_of_online_displays) = Quartz.CGGetOnlineDisplayList( 98 | max_displays, None, None) 99 | if err: 100 | error_exit("Error in obtaining online display list: %s" % err) 101 | 102 | # validate --display option 103 | invalid_display_id = False 104 | invalid_msg = "" 105 | if opts.display <= 0: 106 | invalid_display_id = True 107 | invalid_msg = "Display IDs start at 1." 108 | 109 | if opts.display > number_of_online_displays: 110 | invalid_display_id = True 111 | if number_of_online_displays == 1: 112 | invalid_msg = "There is only one display online." 113 | else: 114 | invalid_msg = "There are only %s displays online." % number_of_online_displays 115 | 116 | if invalid_display_id: 117 | msg = "--display %s is not valid. " % opts.display 118 | msg += invalid_msg 119 | error_exit(msg) 120 | 121 | # Some logic to ensure the first display is always the main one, but 122 | # might confuse things if >2 displays connected. 123 | # main_display_id = Quartz.CGMainDisplayID() 124 | # if number_of_online_displays == 2: 125 | # # main display always seems to be the first ID, but in the opposite 126 | # # case, swap them so it's the first 127 | # if display_ids[1] == main_display_id: 128 | # temp = display_ids[0] 129 | # display_ids[0] = main_display_id 130 | # display_ids[1] = temp 131 | 132 | displays = [] 133 | for index, display_id in enumerate(display_ids): 134 | display = { 135 | 'id': display_id, 136 | 'human_id': index + 1, 137 | 'device_info': ColorSync.ColorSyncDeviceCopyDeviceInfo( 138 | ColorSync.kColorSyncDisplayDeviceClass, ColorSync.CGDisplayCreateUUIDFromDisplayID(display_id)) 139 | } 140 | displays.append(display) 141 | 142 | target_display = displays[opts.display - 1] 143 | 144 | if chosen_action == 'displays': 145 | for display in displays: 146 | print("%s: %s" % (display['human_id'], display['device_info']['DeviceDescription'])) 147 | 148 | if chosen_action == "current-path": 149 | if 'CustomProfiles' in target_display['device_info'].keys(): 150 | current_profile_url = target_display['device_info']['CustomProfiles']['1'] 151 | print(Foundation.CFURLCopyFileSystemPath(current_profile_url, Foundation.kCFURLPOSIXPathStyle)) 152 | 153 | if chosen_action == "info": 154 | pprint(target_display['device_info']) 155 | 156 | if chosen_action in ["set", "unset"]: 157 | if chosen_action == "unset": 158 | profile_url = Foundation.kCFNull 159 | else: 160 | if len(args) < 2: 161 | error_exit("The 'set' action requires a path to an ICC profile as an argument.") 162 | profile_path = args[1] 163 | if not os.path.exists(profile_path): 164 | sys.exit("Can't locate profile at path %s!" % profile_path) 165 | if os.path.isdir(profile_path): 166 | error_exit("'%s' is a directory, not a profile!" % profile_path) 167 | profile_url = Foundation.CFURLCreateFromFileSystemRepresentation(None, profile_path.encode(), 168 | len(profile_path), False) 169 | verify_profile(profile_url) 170 | 171 | user_scope = eval('Foundation.kCFPreferences%sUser' % opts.user_scope.capitalize()) 172 | 173 | # info on config dict required: 174 | # /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ColorSync.framework/Versions/A/Headers/ColorSyncDevice.h 175 | # http://web.archiveorange.com/archive/print/YwdQZYJTswvvG79VTuyD 176 | new_profile_dict = {ColorSync.kColorSyncDeviceDefaultProfileID: profile_url, 177 | ColorSync.kColorSyncProfileUserScope: user_scope} 178 | 179 | success = ColorSync.ColorSyncDeviceSetCustomProfiles( 180 | ColorSync.kColorSyncDisplayDeviceClass, 181 | target_display['device_info']['DeviceID'], 182 | new_profile_dict) 183 | if not success: 184 | error_exit("Setting custom profile was unsuccessful!") 185 | 186 | 187 | if __name__ == '__main__': 188 | main() 189 | -------------------------------------------------------------------------------- /requirements-build.txt: -------------------------------------------------------------------------------- 1 | -r requirements-dev.txt 2 | -r requirements-pyinstaller.txt 3 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | black==22.1.0 4 | click==8.0.4 5 | isort==5.10.1 6 | mypy-extensions==0.4.3 7 | pathspec==0.9.0 8 | platformdirs==2.5.0 9 | tomli==2.0.1 10 | typing_extensions==4.1.1 11 | -------------------------------------------------------------------------------- /requirements-pyinstaller.txt: -------------------------------------------------------------------------------- 1 | altgraph==0.17.3 2 | macholib==1.16.2 3 | pyinstaller==5.4.1 4 | pyinstaller-hooks-contrib==2022.10 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyobjc==8.5.1 2 | -------------------------------------------------------------------------------- /sample-helper-login-script/configure_display_profiles.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # configure_display_profiles.sh 4 | # 5 | # Very simple helper script to run the customdisplayprofiles tool to 6 | # set profiles stored in a known folder location, with subfolders named 7 | # by display index, like in the sample structure below. The icc file itself 8 | # is given only by a shell wildcard, but the tool will only take the first 9 | # argument. 10 | # 11 | # This would allow someone calibrating a display to configure a profile 12 | # for all users simply by copying the profile to the correct folder 13 | # and ensuring it's the only file in this folder. 14 | # 15 | # This script would typically be run at login using a LaunchAgent. 16 | # 17 | # Sample folder hierarchy: 18 | # 19 | # /Library/Org/CustomDisplayProfiles 20 | # ├── 1 21 | # │   └── Custom Profile 1.icc 22 | # └── 2 23 | # └── Custom Profile 2.icc 24 | 25 | 26 | PROFILES_DIR=/Library/Org/CustomDisplayProfiles 27 | TOOL_PATH=/usr/local/bin/customdisplayprofiles 28 | 29 | for DISPLAY_INDEX in $(ls "${PROFILES_DIR}"); do 30 | echo "Setting profile for display $DISPLAY_INDEX..." 31 | $TOOL_PATH set --display $DISPLAY_INDEX "$PROFILES_DIR/$DISPLAY_INDEX"/* 32 | done 33 | --------------------------------------------------------------------------------