├── .flake8 ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── RELEASING.md ├── build_pkg.sh ├── docklib ├── __init__.py └── docklib.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py └── unit.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | select = B,C,E,F,P,W,B9 3 | max-line-length = 80 4 | ### DEFAULT IGNORES FOR 4-space INDENTED PROJECTS ### 5 | # E127, E128 are hard to silence in certain nested formatting situations. 6 | # E203 doesn't work for slicing 7 | # E265, E266 talk about comment formatting which is too opinionated. 8 | # E402 warns on imports coming after statements. There are important use cases 9 | # that require statements before imports. 10 | # E501 is not flexible enough, we're using B950 instead. 11 | # E722 is a duplicate of B001. 12 | # P207 is a duplicate of B003. 13 | # P208 is a duplicate of C403. 14 | # W503 talks about operator formatting which is too opinionated. 15 | ignore = E127, E128, E203, E265, E266, E402, E501, E722, P207, P208, W503 16 | exclude = 17 | .git, 18 | .hg, 19 | max-complexity = 65 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.egg-info 3 | *.pyc 4 | build/ 5 | dist/ 6 | venv/ 7 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | force_grid_wrap=0 3 | include_trailing_comma=True 4 | line_length=88 5 | multi_line_output=3 6 | use_parentheses=True 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | args: [--maxkb=100] 7 | - id: check-ast 8 | - id: check-case-conflict 9 | - id: check-docstring-first 10 | - id: check-merge-conflict 11 | - id: fix-byte-order-marker 12 | - id: mixed-line-ending 13 | - id: no-commit-to-branch 14 | args: [--branch, main] 15 | - id: trailing-whitespace 16 | args: [--markdown-linebreak-ext=md] 17 | - repo: https://github.com/psf/black 18 | rev: 25.1.0 19 | hooks: 20 | - id: black 21 | - repo: https://github.com/asottile/blacken-docs 22 | rev: 1.19.1 23 | hooks: 24 | - id: blacken-docs 25 | additional_dependencies: [black==23.9.1] 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # `docklib` Change Log 2 | 3 | All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## [2.0.0] - 2024-04-13 6 | 7 | This release fixes compatibility with Python 10.12 by removing the dependencies on `distutils.versions`. (Thanks to @arubdesu for #42.) 8 | 9 | ### Removed 10 | 11 | - Removed Python 2 support. If you're still deploying docklib to macOS versions prior to Monterey 12.3, be sure you're deploying a Python runtime like [MacAdmins Python](https://github.com/macadmins/python) rather than relying on the built-in `/usr/bin/python`. 12 | - Removed macOS version detection. This may cause unexpected behavior when referencing the `AllowDockFixupOverride`, `show-recents`, `recent-apps`, `dblclickbehavior`, `show-recents-immutable`, and `windowtabbing` keys on macOS versions prior to Big Sur 11.0. 13 | 14 | ### Changed 15 | 16 | - `makeDockAppSpacer()` parameter name has changed from `type` to `tile_type`. Please update your scripts if you use this function. 17 | - Updated unit tests with new `is-beta` preference key present in macOS Sonoma Dock tiles. 18 | 19 | ## [1.3.0] - 2021-05-31 20 | 21 | The focus of this release is to make docklib functions less focused on dock item labels, since labels can change depending on the user's selected language. (See [#32](https://github.com/homebysix/docklib/issues/32) for details.) 22 | 23 | ### Added 24 | 25 | - A new `findExistingEntry` function that can find dock items based on several attributes. 26 | 27 | The default behavior of `findExistingEntry` is to match the provided string on (in order of preference): 28 | 29 | 1. label 30 | 2. path 31 | 3. filename with extension 32 | 4. filename without extension 33 | 34 | The `match_on` parameter can be specified to select only one of those attributes to match, if desired. See the `findExistingEntry` function docstring for available parameters and values. 35 | 36 | ### Changed 37 | 38 | - The `findExistingLabel` function is now simply a pointer to the new `findExistingEntry` function. `findExistingLabel` will be maintained for backward-compatibility. 39 | 40 | - The `removeDockEntry` function has a new `match_on` parameter that mirrors the same parameter in `findExistingEntry`. Default behavior is to match on the same attributes listed above, in the same order of preference. (This is a change in behavior from previous versions of docklib. If you prefer to continue removing items solely based on label, you should specify `match_on="label"` in your function call.) 41 | 42 | - The `replaceDockEntry` function has two new parameters: 43 | - `match_str`, which allows specifying the item intended to be replaced in the dock (replaces the now deprecated `label` parameter). 44 | - `match_on`, which mirrors the same parameter in `findExistingEntry`. Default behavior is to match on the same attributes listed above, in the same order of preference. 45 | 46 | ### Deprecated 47 | 48 | - The `label` parameter of `replaceDockEntry` is deprecated, and it's encouraged to use `match_str` instead. This allows existing items to be replaced based on multiple attributes rather than just label. As stated above, this makes dock customization scripts more reliable in multilingual environments. 49 | 50 | A warning has been added that alerts administrators to this deprecation. 51 | 52 | - **This is the last release of docklib that will support Python 2.** Future releases will only be tested in Python 3. 53 | 54 | If you haven't started bundling a Python 3 runtime for your management tools, [this blog article from @scriptingosx](https://scriptingosx.com/2020/02/wrangling-pythons/) is a good read. Also: a reminder that docklib is already included in the "recommended" flavor of the [macadmins/python](https://github.com/macadmins/python) packages. 55 | 56 | ## [1.2.1] - 2021-03-01 57 | 58 | ### Added 59 | 60 | - Signed GitHub release package 61 | 62 | ### Fixed 63 | 64 | - Fixed issue preventing `findExistingLabel` from finding URLs' labels 65 | 66 | ## [1.2.0] - 2020-10-15 67 | 68 | (Includes changes from briefly-published versions 1.1.0 and 1.1.1.) 69 | 70 | ### Added 71 | 72 | - Published docklib to PyPI so that administrators can manage it with `pip` and more easily bundle it in [custom Python frameworks](https://github.com/macadmins/python). Adjusted repo file structure to match Python packaging standards. 73 | - Created __build_pkg.sh__ script for creation of macOS package installer. 74 | - Added `findExistingURL` and `removeDockURLEntry` functions for handling URL items. 75 | - Created a few basic unit tests. 76 | 77 | ### Changed 78 | 79 | - Updated pre-commit configuration. 80 | 81 | 82 | ## [1.0.5] - 2020-01-29 83 | 84 | ### Fixed 85 | 86 | - Avoids a TypeError that occurred when a dock "section" was None ([#24](https://github.com/homebysix/docklib/issues/24), fixed by [#25](https://github.com/homebysix/docklib/pull/25)) 87 | 88 | ### Changed 89 | 90 | - Specified UTF-8 encoding on docklib.py file. 91 | 92 | 93 | ## [1.0.4] - 2019-10-08 94 | 95 | ### Fixed 96 | 97 | - Changed `.append()` to `.extend()` for Python 3 compatibility. 98 | 99 | 100 | ## [1.0.3] - 2019-09-07 101 | 102 | ### Added 103 | 104 | - Added more mutable keys (thanks [@WardsParadox](https://github.com/WardsParadox)) 105 | - Added Apache 2.0 license 106 | 107 | ### Fixed 108 | 109 | - Fixed issues with attribute names that contain hyphens (e.g. `mod-count`) 110 | - Fixed issue that caused False values to be skipped 111 | 112 | 113 | ## [1.0.2] - 2019-04-02 114 | 115 | ### Fixed 116 | 117 | - Only use "show-recents" key in 10.14 or higher 118 | 119 | 120 | ## [1.0.1] - 2019-03-24 121 | 122 | ### Added 123 | 124 | - Added the ability to specify a label for Apps 125 | - Added "show-recents" key 126 | - Added pre-commit config for contributors 127 | 128 | ### Changed 129 | 130 | - Standardized Python using Black formatter 131 | - Adopted MunkiPkg project structure 132 | 133 | ### Fixed 134 | 135 | - Corrected examples in read me 136 | - Fixed assignment of return value 137 | 138 | 139 | ## 1.0.0 - 2018-04-19 140 | 141 | - Initial release 142 | 143 | 144 | [Unreleased]: https://github.com/homebysix/docklib/compare/v1.3.0...HEAD 145 | [1.3.0]: https://github.com/homebysix/docklib/compare/v1.2.1...v1.3.0 146 | [1.2.1]: https://github.com/homebysix/docklib/compare/v1.2.0...v1.2.1 147 | [1.2.0]: https://github.com/homebysix/docklib/compare/v1.0.5...v1.2.0 148 | [1.0.5]: https://github.com/homebysix/docklib/compare/v1.0.4...v1.0.5 149 | [1.0.4]: https://github.com/homebysix/docklib/compare/v1.0.3...v1.0.4 150 | [1.0.3]: https://github.com/homebysix/docklib/compare/v1.0.2...v1.0.3 151 | [1.0.2]: https://github.com/homebysix/docklib/compare/v1.0.1...v1.0.2 152 | [1.0.1]: https://github.com/homebysix/docklib/compare/v1.0.0...v1.0.1 153 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Licensed under the Apache License, Version 2.0 (the "License"); 2 | you may not use this source code except in compliance with the License. 3 | You may obtain a copy of the License at 4 | 5 | https://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # docklib 2 | 3 | This is a Python module intended to assist IT administrators with manipulation of the macOS Dock. 4 | 5 | Originally created as a [Gist](https://gist.github.com/gregneagle/5c422d709c93615341a21009f800222e) by @gregneagle, this fork has been modified to include support for some additional Dock features, and has been packaged for multiple distribution options. 6 | 7 | ## docklib or dockutil? 8 | 9 | The very capable [dockutil](https://github.com/kcrawford/dockutil) tool serves a similar function to docklib. Why would Mac admins choose one over the other? 10 | 11 | The primary benefit of **docklib** is that it allows the Dock to be manipulated in a "Pythonic" way. By parsing the Dock configuration into an object with attributes and data structures that can be modified using familiar functions like `.append()` and `.insert()`, docklib aims to make Python scripters feel at home. 12 | 13 | In contrast, **dockutil** behaves more like a shell command-line utility and is written in Swift. This makes dockutil a good choice if you don't have a 'management python' or you're more comfortable writing user setup scripts in bash or zsh. Dockutil also has an `--allhomes` argument that allows Dock configuration for all users to be modified at the same time. Docklib isn't designed for this, instead focusing on configuring the Dock for the user that is currently logged in (for example, via an [outset](https://github.com/macadmins/outset) `login-once` or `login-every` script). [Here's](https://appleshare.it/posts/use-dockutil-in-a-script/) a great article to get you started with dockutil, if that sounds like what you're after. 14 | 15 | ## Installation 16 | 17 | There are multiple methods of installing docklib, depending on how you plan to use it. 18 | 19 | ### Package installer 20 | 21 | You can use the included __build_pkg.sh__ script to build a macOS installer .pkg file. You can use this package to install docklib on your own Mac, or deploy the package using a tool like Jamf or Munki to install docklib on managed devices. 22 | 23 | To run the script, `cd` to a local clone of this repository, then run: 24 | 25 | ``` 26 | ./build_pkg.sh 27 | ``` 28 | 29 | The resulting pkg will be built in a temporary folder and shown in the Finder. 30 | 31 | __NOTE__: The default install destination is __/Library/Python/2.7/site-packages/docklib__, which makes docklib available to the built-in macOS Python 2.7 framework. If you leverage a different Python installation, you'll need to modify this path in the __build_pkg.sh__ script prior to building the installer package. 32 | 33 | ### Pip 34 | 35 | Docklib has been published to PyPI in order to make it available for installation using pip. 36 | 37 | ``` 38 | pip install docklib 39 | ``` 40 | 41 | This method is not intended to be used directly on managed devices, but it could be leveraged alongside a custom Python framework (like one built with [macadmins/python](https://github.com/macadmins/python) or [relocatable-python](https://github.com/gregneagle/relocatable-python)) using a requirements file. 42 | 43 | ### Managed Python 44 | 45 | Docklib is included in the "recommended" flavor of the [macadmins/python](https://github.com/macadmins/python) release package. Installing this package and using `#!/usr/local/managed_python3` for your docklib script shebang may be the most self-contained and future-proof way to deploy docklib. 46 | 47 | ### Manual 48 | 49 | Another method of using docklib is to simply place the docklib.py file in the same location as the Python script(s) you use to manipulate the macOS dock. Some examples of such scripts are included below. 50 | 51 | ## Examples 52 | 53 | ### Add Microsoft Word to the right side of the Dock 54 | 55 | ```python 56 | from docklib import Dock 57 | 58 | dock = Dock() 59 | item = dock.makeDockAppEntry("/Applications/Microsoft Word.app") 60 | dock.items["persistent-apps"].append(item) 61 | dock.save() 62 | ``` 63 | 64 | ### Add Microsoft Word to the left side of the Dock 65 | 66 | ```python 67 | from docklib import Dock 68 | 69 | dock = Dock() 70 | item = dock.makeDockAppEntry("/Applications/Microsoft Word.app") 71 | dock.items["persistent-apps"] = [item] + dock.items["persistent-apps"] 72 | dock.save() 73 | ``` 74 | 75 | ### Replace Mail.app with Outlook in the Dock 76 | 77 | ```python 78 | from docklib import Dock 79 | 80 | dock = Dock() 81 | dock.replaceDockEntry("/Applications/Microsoft Outlook.app", "Mail") 82 | dock.save() 83 | ``` 84 | 85 | ### Remove Calendar from the Dock 86 | 87 | ```python 88 | from docklib import Dock 89 | 90 | dock = Dock() 91 | dock.removeDockEntry("Calendar") 92 | dock.save() 93 | ``` 94 | 95 | ### Display the current orientation of the Dock 96 | 97 | ```python 98 | from docklib import Dock 99 | 100 | dock = Dock() 101 | print(dock.orientation) 102 | ``` 103 | 104 | ### Make the Dock display on the left, and enable autohide 105 | 106 | ```python 107 | from docklib import Dock 108 | 109 | dock = Dock() 110 | dock.orientation = "left" 111 | dock.autohide = True 112 | dock.save() 113 | ``` 114 | 115 | ### Add the Documents folder to the right side of the Dock 116 | 117 | Displays as a stack to the right of the Dock divider, sorted by modification date, that expands into a fan when clicked. This example checks for the existence of the Documents item and only adds it if it's not already present. 118 | 119 | ```python 120 | import os 121 | from docklib import Dock 122 | 123 | dock = Dock() 124 | if dock.findExistingEntry("Documents", section="persistent-others") == -1: 125 | item = dock.makeDockOtherEntry( 126 | os.path.expanduser("~/Documents"), arrangement=3, displayas=1, showas=1 127 | ) 128 | dock.items["persistent-others"] = [item] + dock.items["persistent-others"] 129 | dock.save() 130 | ``` 131 | ### Add a URL to the right side of the Dock 132 | 133 | Displays as a globe to the right of the Dock divider, that launches a URL in the default browser when clicked. This example checks for the existence of the Documents item and only adds it if it's not already present. 134 | 135 | ```python 136 | import os 137 | from docklib import Dock 138 | 139 | dock = Dock() 140 | if dock.findExistingEntry("GitHub", section="persistent-others") == -1: 141 | item = dock.makeDockOtherURLEntry("https://www.github.com/", label="GitHub") 142 | dock.items["persistent-others"] = [item] + dock.items["persistent-others"] 143 | dock.save() 144 | ``` 145 | 146 | ### Specify a custom Dock for the local IT technician account 147 | 148 | ```python 149 | import os 150 | from docklib import Dock 151 | 152 | tech_dock = [ 153 | "/Applications/Google Chrome.app", 154 | "/Applications/App Store.app", 155 | "/Applications/Managed Software Center.app", 156 | "/Applications/System Preferences.app", 157 | "/Applications/Utilities/Activity Monitor.app", 158 | "/Applications/Utilities/Console.app", 159 | "/Applications/Utilities/Disk Utility.app", 160 | "/Applications/Utilities/Migration Assistant.app", 161 | "/Applications/Utilities/Terminal.app", 162 | ] 163 | dock = Dock() 164 | dock.items["persistent-apps"] = [] 165 | for item in tech_dock: 166 | if os.path.exists(item): 167 | item = dock.makeDockAppEntry(item) 168 | dock.items["persistent-apps"].append(item) 169 | dock.save() 170 | ``` 171 | 172 | Or if you prefer using a [list comprehension](https://www.pythonforbeginners.com/basics/list-comprehensions-in-python): 173 | 174 | ```python 175 | import os 176 | from docklib import Dock 177 | 178 | tech_dock = [ 179 | "/Applications/Google Chrome.app", 180 | "/Applications/App Store.app", 181 | "/Applications/Managed Software Center.app", 182 | "/Applications/System Preferences.app", 183 | "/Applications/Utilities/Activity Monitor.app", 184 | "/Applications/Utilities/Console.app", 185 | "/Applications/Utilities/Disk Utility.app", 186 | "/Applications/Utilities/Migration Assistant.app", 187 | "/Applications/Utilities/Terminal.app", 188 | ] 189 | dock = Dock() 190 | dock.items["persistent-apps"] = [ 191 | dock.makeDockAppEntry(item) for item in tech_dock if os.path.exists(item) 192 | ] 193 | dock.save() 194 | ``` 195 | 196 | ## More information 197 | 198 | For more examples and tips for creating your docklib script, see my guides on: 199 | 200 | - [Writing Resilient Docklib Scripts](https://www.elliotjordan.com/posts/resilient-docklib/) 201 | - [Deploying and running docklib scripts using Outset](https://www.elliotjordan.com/posts/docklib-outset/) 202 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing new versions of docklib 2 | 3 | ## Requirements 4 | 5 | - Local clone of this repository 6 | - Twine (`pip3 install twine`) 7 | - Account on test.pypi.org 8 | - Account on pypi.org 9 | 10 | ## Steps 11 | 12 | 1. Ensure the version in __docklib/\_\_init\_\_.py__ has been updated. 13 | 14 | 1. Ensure the change log has been updated and reflects actual release date. 15 | 16 | 1. Merge development branch to main/master branch. 17 | 18 | 1. Run docklib unit tests and fix any errors: 19 | 20 | managed_python3 -m unittest -v tests.unit 21 | 22 | 1. Build a new distribution package: 23 | 24 | rm -fv dist/* 25 | python3 setup.py sdist bdist_wheel 26 | 27 | 1. Upload package to test.pypi.org: 28 | 29 | twine upload --repository-url https://test.pypi.org/legacy/ dist/* 30 | 31 | 1. View resulting project on test.pypi.org and make sure it looks good. 32 | 33 | 1. Install test docklib in MacAdmins Python on a test Mac: 34 | 35 | managed_python3 -m pip install --upgrade -i https://test.pypi.org/simple/ docklib 36 | 37 | 1. Perform tests - manual for now. 38 | 39 | 1. Upload package to pypi.org: 40 | 41 | twine upload dist/* 42 | 43 | 1. View resulting project on pypi.org and make sure it looks good. 44 | 45 | 1. Install production docklib in MacAdmins Python on a test Mac: 46 | 47 | managed_python3 -m pip install --upgrade docklib 48 | 49 | 1. Build new installer package using __build_pkg.sh__: 50 | 51 | ./build_pkg.sh 52 | 53 | By default the resulting package is unsigned. To sign the package, provide the name of the signing certificate from your macOS keychain. 54 | 55 | ./build_pkg.sh "Developer ID Installer: John Doe (ABCDE12345)" 56 | 57 | 1. Create new [release](https://github.com/homebysix/docklib/releases) on GitHub. Add notes from change log. Attach built installer package. 58 | 59 | 1. Announce to [dock-management](https://macadmins.slack.com/archives/C17NRH534) and other relevant channels, if desired. 60 | -------------------------------------------------------------------------------- /build_pkg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Name of pkg signing certificate in keychain. (Default to unsigned.) 4 | CERTNAME="$1" 5 | 6 | cd "$(dirname "$0")" || exit 1 7 | 8 | echo "Preparing source folder..." 9 | rm -rfv ./docklib/__pycache__ 10 | rm -fv ./docklib/*.pyc 11 | 12 | echo "Preparing pkgroot and output folders..." 13 | PKGROOT=$(mktemp -d /tmp/docklib-build-root-XXXXXXXXXXX) 14 | OUTPUTDIR=$(mktemp -d /tmp/docklib-output-XXXXXXXXXXX) 15 | mkdir -p "$PKGROOT/Library/Python/2.7/site-packages/" 16 | 17 | echo "Copying docklib into pkgroot..." 18 | # Customize this path if you're not using the macOS built-in Python 2.7 with docklib. 19 | cp -R ./docklib "$PKGROOT/Library/Python/2.7/site-packages/docklib" 20 | 21 | echo "Determining version..." 22 | VERSION=$(awk -F \" '/version/{print $2}' docklib/__init__.py) 23 | echo " Version: $VERSION" 24 | 25 | echo "Building package..." 26 | OUTFILE="$OUTPUTDIR/docklib-$VERSION.pkg" 27 | pkgbuild --root "$PKGROOT" --identifier com.elliotjordan.docklib --version "$VERSION" "$OUTFILE" 28 | 29 | if [[ -n $CERTNAME ]]; then 30 | echo "Signing package..." 31 | productsign --sign "$CERTNAME" "$OUTFILE" "${OUTFILE/.pkg/-signed.pkg}" 32 | mv "${OUTFILE/.pkg/-signed.pkg}" "$OUTFILE" 33 | fi 34 | 35 | echo "$OUTFILE" 36 | open "$OUTPUTDIR" 37 | -------------------------------------------------------------------------------- /docklib/__init__.py: -------------------------------------------------------------------------------- 1 | from .docklib import * 2 | 3 | __version__ = "2.0.0" 4 | -------------------------------------------------------------------------------- /docklib/docklib.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=C0103 2 | 3 | """Python module intended to assist IT administrators with manipulation of the macOS Dock. 4 | 5 | See project details on GitHub: https://github.com/homebysix/docklib 6 | """ 7 | 8 | import os 9 | import subprocess 10 | from urllib.parse import unquote, urlparse 11 | 12 | # pylint: disable=E0611 13 | from Foundation import ( 14 | NSURL, 15 | CFPreferencesAppSynchronize, 16 | CFPreferencesCopyAppValue, 17 | CFPreferencesSetAppValue, 18 | ) 19 | 20 | # pylint: enable=E0611 21 | 22 | 23 | class DockError(Exception): 24 | """Basic exception.""" 25 | 26 | pass 27 | 28 | 29 | class Dock: 30 | """Class to handle Dock operations.""" 31 | 32 | _DOMAIN = "com.apple.dock" 33 | _DOCK_PLIST = os.path.expanduser("~/Library/Preferences/com.apple.dock.plist") 34 | _DOCK_LAUNCHAGENT_ID = "com.apple.Dock.agent" 35 | _DOCK_LAUNCHAGENT_FILE = "/System/Library/LaunchAgents/com.apple.Dock.plist" 36 | # TODO: static-apps and static-others 37 | _SECTIONS = ["persistent-apps", "persistent-others"] 38 | _MUTABLE_KEYS = [ 39 | "autohide", 40 | "autohide-immutable", 41 | "contents-immutable", 42 | "dblclickbehavior", 43 | "largesize", 44 | "launchanim", 45 | "launchanim-immutable", 46 | "magnification", 47 | "magnification-immutable", 48 | "magsize-immutable", 49 | "mineffect", 50 | "mineffect-immutable", 51 | "minimize-to-application", 52 | "minimize-to-application-immutable", 53 | "orientation", 54 | "orientation-immutable", 55 | "position-immutable", 56 | "tilesize", 57 | "show-process-indicators", 58 | "show-progress-indicators", 59 | "show-recents", 60 | "show-recents-immutable", 61 | "size-immutable", 62 | "windowtabbing", 63 | "AllowDockFixupOverride", 64 | ] 65 | 66 | _IMMUTABLE_KEYS = ["mod-count", "recent-apps", "trash-full"] 67 | 68 | items = {} 69 | 70 | def __init__(self): 71 | for key in self._SECTIONS: 72 | try: 73 | section = CFPreferencesCopyAppValue(key, self._DOMAIN) 74 | self.items[key] = section.mutableCopy() if section else None 75 | except Exception: 76 | raise 77 | for key in self._MUTABLE_KEYS + self._IMMUTABLE_KEYS: 78 | try: 79 | value = CFPreferencesCopyAppValue(key, self._DOMAIN) 80 | setattr(self, key.replace("-", "_"), value) 81 | except Exception: 82 | raise 83 | 84 | def save(self): 85 | """Saves our (modified) Dock preferences.""" 86 | # unload Dock launchd job so we can make our changes unmolested 87 | subprocess.call(["/bin/launchctl", "unload", self._DOCK_LAUNCHAGENT_FILE]) 88 | 89 | for key in self._SECTIONS: 90 | try: 91 | CFPreferencesSetAppValue(key, self.items[key], self._DOMAIN) 92 | except Exception as exc: 93 | raise DockError from exc 94 | for key in self._MUTABLE_KEYS: 95 | # Python doesn't support hyphens in attribute names, so convert 96 | # to/from underscores as needed. 97 | if getattr(self, key.replace("-", "_")) is not None: 98 | try: 99 | CFPreferencesSetAppValue( 100 | key.replace("_", "-"), 101 | getattr(self, key.replace("-", "_")), 102 | self._DOMAIN, 103 | ) 104 | except Exception as exc: 105 | raise DockError from exc 106 | if not CFPreferencesAppSynchronize(self._DOMAIN): 107 | raise DockError 108 | 109 | # restart the Dock 110 | subprocess.call(["/bin/launchctl", "load", self._DOCK_LAUNCHAGENT_FILE]) 111 | subprocess.call(["/bin/launchctl", "start", self._DOCK_LAUNCHAGENT_ID]) 112 | 113 | def findExistingEntry(self, match_str, match_on="any", section="persistent-apps"): 114 | """Returns index of a Dock item identified by match_str or -1 if no item 115 | is found. 116 | 117 | match_on values: 118 | label: match dock items with this file-label or label 119 | (e.g. Safari) 120 | NOTE: Labels can vary depending on the user's selected language. 121 | path: match dock items with this path on disk 122 | (e.g. /System/Applications/Safari.app) 123 | name_ext: match dock items with this file/folder basename 124 | (e.g. Safari.app) 125 | name_noext: match dock items with this basename, without extension 126 | (e.g. Safari) 127 | any: Try all the criteria above in order, and return the first result. 128 | """ 129 | section_items = self.items[section] 130 | if section_items: 131 | # Determine the order of attributes to match on. 132 | if match_on == "any": 133 | match_ons = ["label", "path", "name_ext", "name_noext"] 134 | else: 135 | match_ons = [match_on] 136 | 137 | # Iterate through match criteria (ensures a full scan of the Dock 138 | # for each criterion, if matching on "any"). 139 | for m in match_ons: 140 | # Iterate through items in section 141 | for index, item in enumerate(section_items): 142 | url = item["tile-data"].get("file-data", {}).get("_CFURLString", "") 143 | path = unquote(urlparse(url.rstrip("/")).path) 144 | name_ext = os.path.basename(path) 145 | name_noext = os.path.splitext(name_ext)[0] 146 | 147 | if m == "label": 148 | # Most dock items use "file-label", but URLs use "label" 149 | for label_key in ("file-label", "label"): 150 | if item["tile-data"].get(label_key) == match_str: 151 | return index 152 | elif m == "path" and path == match_str: 153 | return index 154 | elif m == "name_ext" and name_ext == match_str: 155 | return index 156 | elif m == "name_noext" and name_noext == match_str: 157 | return index 158 | 159 | return -1 160 | 161 | def findExistingLabel(self, match_str, section="persistent-apps"): 162 | """Points to findExistingEntry, maintained for compatibility.""" 163 | return self.findExistingEntry(match_str, match_on="label", section=section) 164 | 165 | def findExistingURL(self, match_url): 166 | """Returns index of item with URL matching match_url or -1 if not 167 | found.""" 168 | section_items = self.items["persistent-others"] 169 | if section_items: 170 | for index, item in enumerate(section_items): 171 | if item["tile-data"].get("url"): 172 | if item["tile-data"]["url"]["_CFURLString"] == match_url: 173 | return index 174 | 175 | return -1 176 | 177 | def removeDockEntry(self, match_str, match_on="any", section=None): 178 | """Removes a Dock entry identified by "match_str", if any. Defaults to 179 | matching "match_str" by the "any" criteria order listed in the 180 | findExistingEntry docstring.""" 181 | if section: 182 | sections = [section] 183 | else: 184 | sections = self._SECTIONS 185 | for sect in sections: 186 | found_index = self.findExistingEntry( 187 | match_str, match_on=match_on, section=sect 188 | ) 189 | if found_index > -1: 190 | del self.items[sect][found_index] 191 | 192 | def removeDockURLEntry(self, url): 193 | """Removes a Dock entry with matching url, if any.""" 194 | found_index = self.findExistingURL(url) 195 | if found_index > -1: 196 | del self.items["persistent-others"][found_index] 197 | 198 | def replaceDockEntry( 199 | self, 200 | newpath, 201 | match_str=None, 202 | match_on="any", 203 | label=None, # deprecated 204 | section="persistent-apps", 205 | ): 206 | """Replaces a Dock entry. 207 | 208 | If match_str is provided, it will be used to match the item to be replaced. 209 | See the findExistingEntry function docstring for possible "match_on" values. 210 | 211 | If match_str is not provided, the item to be replaced will be derived from 212 | the newpath filename, without extension. 213 | 214 | The "label" parameter is deprecated in favor of match_str and will be 215 | removed someday. 216 | """ 217 | if section == "persistent-apps": 218 | newitem = self.makeDockAppEntry(newpath) 219 | else: 220 | newitem = self.makeDockOtherEntry(newpath) 221 | if not newitem: 222 | return 223 | if label: 224 | print( 225 | "WARNING: The label parameter is deprecated. Use match_str instead. " 226 | "Details: https://github.com/homebysix/docklib/issues/32" 227 | ) 228 | match_str = label 229 | match_on = "label" 230 | if not match_str: 231 | match_str = os.path.splitext(os.path.basename(newpath))[0] 232 | found_index = self.findExistingEntry( 233 | match_str, match_on=match_on, section=section 234 | ) 235 | if found_index > -1: 236 | self.items[section][found_index] = newitem 237 | 238 | def makeDockAppSpacer(self, tile_type="spacer-tile"): 239 | """Makes an empty space in the Dock.""" 240 | if tile_type not in ["spacer-tile", "small-spacer-tile"]: 241 | msg = f"{tile_type}: invalid makeDockAppSpacer type." 242 | raise ValueError(msg) 243 | result = {"tile-data": {}, "tile-type": tile_type} 244 | 245 | return result 246 | 247 | def makeDockAppEntry(self, thePath, label_name=None): 248 | """Returns a dictionary corresponding to a Dock application item.""" 249 | if not label_name: 250 | label_name = os.path.splitext(os.path.basename(thePath))[0] 251 | ns_url = NSURL.fileURLWithPath_(thePath).absoluteString() 252 | result = { 253 | "tile-data": { 254 | "file-data": {"_CFURLString": ns_url, "_CFURLStringType": 15}, 255 | "file-label": label_name, 256 | "file-type": 41, 257 | }, 258 | "tile-type": "file-tile", 259 | } 260 | 261 | return result 262 | 263 | def makeDockOtherEntry(self, thePath, arrangement=0, displayas=1, showas=0): 264 | """Returns a dictionary corresponding to a Dock folder or file item. 265 | 266 | arrangement values: 267 | 1: sort by name 268 | 2: sort by date added 269 | 3: sort by modification date 270 | 4: sort by creation date 271 | 5: sort by kind 272 | displayas values: 273 | 0: display as stack 274 | 1: display as folder 275 | showas values: 276 | 0: auto 277 | 1: fan 278 | 2: grid 279 | 3: list 280 | """ 281 | 282 | label_name = os.path.splitext(os.path.basename(thePath))[0] 283 | if arrangement == 0: 284 | if label_name == "Downloads": 285 | # set to sort by date added 286 | arrangement = 2 287 | else: 288 | # set to sort by name 289 | arrangement = 1 290 | ns_url = NSURL.fileURLWithPath_(thePath).absoluteString() 291 | if os.path.isdir(thePath): 292 | result = { 293 | "tile-data": { 294 | "arrangement": arrangement, 295 | "displayas": displayas, 296 | "file-data": {"_CFURLString": ns_url, "_CFURLStringType": 15}, 297 | "file-label": label_name, 298 | "dock-extra": False, 299 | "showas": showas, 300 | }, 301 | "tile-type": "directory-tile", 302 | } 303 | else: 304 | result = { 305 | "tile-data": { 306 | "file-data": {"_CFURLString": ns_url, "_CFURLStringType": 15}, 307 | "file-label": label_name, 308 | "dock-extra": False, 309 | }, 310 | "tile-type": "file-tile", 311 | } 312 | 313 | return result 314 | 315 | def makeDockOtherURLEntry(self, theURL, label=None): 316 | """Returns a dictionary corresponding to a URL.""" 317 | if label is None: 318 | label_name = str(theURL) 319 | else: 320 | label_name = label 321 | ns_url = NSURL.URLWithString_(theURL).absoluteString() 322 | result = { 323 | "tile-data": { 324 | "label": label_name, 325 | "url": {"_CFURLString": ns_url, "_CFURLStringType": 15}, 326 | }, 327 | "tile-type": "url-tile", 328 | } 329 | 330 | return result 331 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyobjc==10.1 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """setup.py for docklib""" 2 | 3 | import pathlib 4 | 5 | from setuptools import setup 6 | 7 | # The directory containing this file 8 | HERE = pathlib.Path(__file__).parent 9 | 10 | # The text of the README file 11 | README = (HERE / "README.md").read_text() 12 | 13 | 14 | def get_version(rel_path): 15 | """Given a path to a Python init file, return the version string.""" 16 | with open(rel_path, "r") as openfile: 17 | lines = openfile.readlines() 18 | for line in lines: 19 | if line.startswith("__version__"): 20 | delim = '"' if '"' in line else "'" 21 | return line.split(delim)[1] 22 | raise RuntimeError("Unable to find version string.") 23 | 24 | 25 | setup( 26 | name="docklib", 27 | version=get_version("docklib/__init__.py"), 28 | description=( 29 | "Python module intended to assist IT " 30 | "administrators with manipulation of the macOS Dock." 31 | ), 32 | long_description=README, 33 | long_description_content_type="text/markdown", 34 | url="https://github.com/homebysix/docklib", 35 | author="Elliot Jordan", 36 | author_email="elliot@elliotjordan.com", 37 | license="Apache 2.0", 38 | classifiers=[ 39 | "Development Status :: 5 - Production/Stable", 40 | "Environment :: MacOS X", 41 | "Intended Audience :: Information Technology", 42 | "Intended Audience :: System Administrators", 43 | "License :: OSI Approved :: Apache Software License", 44 | "Programming Language :: Python :: 2", 45 | "Programming Language :: Python :: 3", 46 | "Topic :: System :: Systems Administration", 47 | "Topic :: Utilities", 48 | ], 49 | packages=["docklib"], 50 | include_package_data=True, 51 | ) 52 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homebysix/docklib/5c1aa40d07826a0fa5e09c800f332255f69b6e8e/tests/__init__.py -------------------------------------------------------------------------------- /tests/unit.py: -------------------------------------------------------------------------------- 1 | """unit.py 2 | 3 | Unit tests for docklib. NOTE: These tests are designed to mutate the logged in 4 | user's dock. If all tests pass, the end state should be the same as the 5 | beginning state, but be aware that there is some possibility of undesired 6 | modifications remaining if the tests fail. 7 | 8 | To run tests: 9 | managed_python3 -m unittest -v tests.unit 10 | """ 11 | 12 | import os 13 | import types 14 | import unittest 15 | from time import sleep 16 | 17 | import docklib 18 | 19 | 20 | class TestDocklib(unittest.TestCase): 21 | """Unit test class. Functions are numbered in order to ensure a specific 22 | execution order.""" 23 | 24 | def setUp(self): 25 | self.dock = docklib.Dock() 26 | 27 | def test_00_import(self): 28 | """Ensure docklib imports successfully as a module.""" 29 | self.assertIs(type(docklib), types.ModuleType) 30 | 31 | def test_05_init(self): 32 | """Ensure docklib successfully reads the macOS dock.""" 33 | self.assertIsInstance(self.dock, docklib.Dock) 34 | 35 | def test_10_sections(self): 36 | """Ensure docklib retrieves the expected dock sections.""" 37 | actual_sections = list(self.dock.items.keys()) 38 | # pylint: disable=W0212 39 | self.assertEqual(sorted(actual_sections), sorted(docklib.Dock._SECTIONS)) 40 | # pylint: enable=W0212 41 | 42 | def test_15_item_keys(self): 43 | """Ensure docklib does not encounter unexpected dock item keys.""" 44 | sections = ["persistent-apps", "persistent-others"] 45 | actual_keys = [] 46 | expected_keys = ["tile-type", "GUID", "tile-data"] 47 | for section in sections: 48 | for item in self.dock.items[section]: 49 | actual_keys.extend(list(item.keys())) 50 | actual_keys = list(set(actual_keys)) 51 | self.assertEqual(sorted(actual_keys), sorted(expected_keys)) 52 | 53 | def test_20_tile_data_keys(self): 54 | """Ensure docklib does not encounter unexpected tile-data keys.""" 55 | sections = ["persistent-apps", "persistent-others"] 56 | expected_keys = [ 57 | "arrangement", 58 | "book", 59 | "bundle-identifier", 60 | "displayas", 61 | "dock-extra", 62 | "file-data", 63 | "file-label", 64 | "file-mod-date", 65 | "file-type", 66 | "is-beta", 67 | "label", 68 | "parent-mod-date", 69 | "preferreditemsize", 70 | "showas", 71 | "url", 72 | ] 73 | for section in sections: 74 | for item in self.dock.items[section]: 75 | for key in item["tile-data"].keys(): 76 | self.assertIn(key, expected_keys) 77 | 78 | def test_25_tile_types(self): 79 | """Ensure docklib does not encounter unexpected tile-types.""" 80 | sections = ["persistent-apps", "persistent-others"] 81 | expected_types = [ 82 | "directory-tile", 83 | "file-tile", 84 | "small-spacer-tile", 85 | "spacer-tile", 86 | "url-tile", 87 | ] 88 | for section in sections: 89 | for item in self.dock.items[section]: 90 | self.assertIn(item["tile-type"], expected_types) 91 | 92 | def test_30_add_app(self): 93 | """Ensure docklib can add apps to the dock.""" 94 | item = self.dock.makeDockAppEntry("/System/Applications/Chess.app") 95 | old_len = len(self.dock.items["persistent-apps"]) 96 | self.dock.items["persistent-apps"].append(item) 97 | self.dock.save() 98 | sleep(2) 99 | new_len = len(self.dock.items["persistent-apps"]) 100 | self.assertEqual(new_len, old_len + 1) 101 | 102 | def test_33_find_label(self): 103 | """Ensure docklib can find apps in the dock using findExistingLabel.""" 104 | # NOTE: Only works if test environment is set to English language. 105 | app_idx = self.dock.findExistingLabel("Chess", section="persistent-apps") 106 | self.assertGreaterEqual(app_idx, 0) 107 | 108 | app_idx = self.dock.findExistingLabel("FooBarApp", section="persistent-apps") 109 | self.assertEqual(app_idx, -1) 110 | 111 | def test_35_find_entry_any(self): 112 | """Ensure docklib can find apps in the dock by any attribute.""" 113 | app_idx = self.dock.findExistingEntry("Chess", section="persistent-apps") 114 | self.assertGreaterEqual(app_idx, 0) 115 | 116 | app_idx = self.dock.findExistingEntry("FooBarApp", section="persistent-apps") 117 | self.assertEqual(app_idx, -1) 118 | 119 | def test_36_find_entry_label(self): 120 | """Ensure docklib can find apps in the dock by label.""" 121 | # NOTE: Only works if test environment is set to English language. 122 | app_idx = self.dock.findExistingEntry( 123 | "Chess", match_on="label", section="persistent-apps" 124 | ) 125 | self.assertGreaterEqual(app_idx, 0) 126 | 127 | app_idx = self.dock.findExistingEntry( 128 | "FooBarApp", match_on="label", section="persistent-apps" 129 | ) 130 | self.assertEqual(app_idx, -1) 131 | 132 | def test_37_find_entry_path(self): 133 | """Ensure docklib can find apps in the dock by path.""" 134 | app_idx = self.dock.findExistingEntry( 135 | "/System/Applications/Chess.app", match_on="path", section="persistent-apps" 136 | ) 137 | self.assertGreaterEqual(app_idx, 0) 138 | 139 | app_idx = self.dock.findExistingEntry( 140 | "/Applications/FooBarApp.app", match_on="path", section="persistent-apps" 141 | ) 142 | self.assertEqual(app_idx, -1) 143 | 144 | def test_38_find_entry_ext(self): 145 | """Ensure docklib can find apps in the dock by filename with extension.""" 146 | app_idx = self.dock.findExistingEntry( 147 | "Chess.app", match_on="name_ext", section="persistent-apps" 148 | ) 149 | self.assertGreaterEqual(app_idx, 0) 150 | 151 | app_idx = self.dock.findExistingEntry( 152 | "FooBarApp.app", match_on="name_ext", section="persistent-apps" 153 | ) 154 | self.assertEqual(app_idx, -1) 155 | 156 | def test_39_find_entry_noext(self): 157 | """Ensure docklib can find apps in the dock by filename without extension.""" 158 | app_idx = self.dock.findExistingEntry( 159 | "Chess", match_on="name_noext", section="persistent-apps" 160 | ) 161 | self.assertGreaterEqual(app_idx, 0) 162 | 163 | app_idx = self.dock.findExistingEntry( 164 | "FooBarApp", match_on="name_noext", section="persistent-apps" 165 | ) 166 | self.assertEqual(app_idx, -1) 167 | 168 | def test_40_remove_app(self): 169 | """Ensure docklib can remove apps from the dock.""" 170 | old_len = len(self.dock.items["persistent-apps"]) 171 | self.dock.removeDockEntry("Chess") 172 | self.dock.save() 173 | sleep(2) 174 | new_len = len(self.dock.items["persistent-apps"]) 175 | self.assertEqual(new_len, old_len - 1) 176 | 177 | def test_45_add_other(self): 178 | """Ensure docklib can add other items to the dock.""" 179 | item = self.dock.makeDockOtherEntry( 180 | os.path.expanduser("~/Library/Application Support") 181 | ) 182 | old_len = len(self.dock.items["persistent-others"]) 183 | self.dock.items["persistent-others"].append(item) 184 | self.dock.save() 185 | sleep(2) 186 | new_len = len(self.dock.items["persistent-others"]) 187 | self.assertEqual(new_len, old_len + 1) 188 | 189 | def test_50_find_other(self): 190 | """Ensure docklib can find other items in the dock.""" 191 | other_idx = self.dock.findExistingLabel( 192 | "Application Support", section="persistent-others" 193 | ) 194 | self.assertGreaterEqual(other_idx, 0) 195 | 196 | other_idx = self.dock.findExistingLabel( 197 | "FooBarOther", section="persistent-others" 198 | ) 199 | self.assertEqual(other_idx, -1) 200 | 201 | def test_55_remove_other(self): 202 | """Ensure docklib can remove other items from the dock.""" 203 | old_len = len(self.dock.items["persistent-others"]) 204 | self.dock.removeDockEntry("Application Support") 205 | self.dock.save() 206 | sleep(2) 207 | new_len = len(self.dock.items["persistent-others"]) 208 | self.assertEqual(new_len, old_len - 1) 209 | 210 | def test_60_add_url(self): 211 | """Ensure docklib can add url items to the dock.""" 212 | item = self.dock.makeDockOtherURLEntry("https://www.apple.com", "Apple Inc") 213 | old_len = len(self.dock.items["persistent-others"]) 214 | self.dock.items["persistent-others"].append(item) 215 | self.dock.save() 216 | sleep(2) 217 | new_len = len(self.dock.items["persistent-others"]) 218 | self.assertEqual(new_len, old_len + 1) 219 | 220 | def test_65_find_url(self): 221 | """Ensure docklib can find url items in the dock.""" 222 | other_idx = self.dock.findExistingLabel( 223 | "Apple Inc", section="persistent-others" 224 | ) 225 | self.assertGreaterEqual(other_idx, 0) 226 | 227 | def test_70_remove_url(self): 228 | """Ensure docklib can remove url items from the dock.""" 229 | old_len = len(self.dock.items["persistent-others"]) 230 | self.dock.removeDockURLEntry("https://www.apple.com") 231 | self.dock.save() 232 | sleep(2) 233 | new_len = len(self.dock.items["persistent-others"]) 234 | self.assertEqual(new_len, old_len - 1) 235 | 236 | def test_75_add_spacer(self): 237 | """Ensure docklib can add a spacer item to the dock.""" 238 | item = self.dock.makeDockAppSpacer() 239 | old_len = len(self.dock.items["persistent-apps"]) 240 | self.dock.items["persistent-apps"].insert(0, item) 241 | self.dock.save() 242 | sleep(2) 243 | new_len = len(self.dock.items["persistent-apps"]) 244 | self.assertEqual(new_len, old_len + 1) 245 | 246 | def test_80_remove_spacer(self): 247 | """Ensure docklib can remove a spacer item from the dock.""" 248 | old_len = len(self.dock.items["persistent-apps"]) 249 | del self.dock.items["persistent-apps"][0] 250 | self.dock.save() 251 | sleep(2) 252 | new_len = len(self.dock.items["persistent-apps"]) 253 | self.assertEqual(new_len, old_len - 1) 254 | 255 | 256 | if __name__ == "__main__": 257 | unittest.main() 258 | --------------------------------------------------------------------------------