Last active
September 7, 2023 19:58
-
-
Save jord-nijhuis/04cb2f0d9c71e82b2b1e1d614794793d to your computer and use it in GitHub Desktop.
[MacOS] Patch application icons
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# USAGE: update_icons.py [path to icon directory] | |
# | |
# Patch Applications with icons that have matching names from the icon directory. | |
# | |
# This script requires root privileges (since it is writing to /Applications). | |
# The `pyobjc` dependency is required for running this script (`pip3 install pyobjc`) | |
# | |
# If the icon directory is left empty, the current working directory will be used. | |
# By default, this script will search in the following directories: | |
# 1) `/Applications`; | |
# 2) `~/Applications`; | |
# 3) `~/Library/Application Support/JetBrains/Toolbox/apps`; | |
# 4) `/Applications/Docker.app/Contents/MacOS`; | |
# 5) `/Applications/Alfred 4.app/Contents/Preferences`; | |
# 6) `~/Library/Application Support/Steam/Steam.AppBundle`; | |
# 7) `~/Library/Application Support/Steam/steamapps/common`. | |
# To change this, modify the `APPLICATIONS_PATH` variable below | |
# | |
# You can also mark specific folders as applications by modifying the `APPLICATIONS` variable. T | |
# his is useful for when the folder does not have the .app extension. By default, the following | |
# applications have been defined: | |
# 1) `~/Library/Application Support/Steam/Steam.AppBundle/Steam`. | |
# | |
# Example: | |
# To patch `YourApp.app` with an icon, you have to place an icon with the name `YourApp.icns` in the icon folder and | |
# run this program. Keep in mind that the name displayed in Finder might now be the name of the actual app (See "Name | |
# & Extension" under "Get Info" for the actual name). | |
# | |
# See https://macosicons.com for a website where you can find macOS icons. | |
# | |
# For the latest version of this script, see https://gist.github.com/jord-nijhuis/04cb2f0d9c71e82b2b1e1d614794793d | |
import glob | |
import os | |
import sys | |
from typing import List, Optional | |
from Cocoa import NSWorkspace, NSImage | |
# A list of directories where applications might be located | |
# | |
# Subdirectories of the defined paths (except for the application itself will also be iterated over | |
APPLICATION_PATHS = [ | |
'/Applications', # Global applications | |
os.path.expanduser('~/Applications'), # User applications | |
os.path.expanduser('~/Library/Application Support/JetBrains/Toolbox/apps'), # Jetbrains Toolbox | |
'/Applications/Docker.app/Contents/MacOS', # Docker Desktop | |
'/Applications/Alfred 4.app/Contents/Preferences', # Alfred Preferences | |
os.path.expanduser('~/Library/Application Support/Steam/steamapps/common') # Steam Games | |
] | |
# A list of applications | |
# | |
# You can use this list to define applications that do not fall under the APPLICATION_PATHS | |
APPLICATIONS = [ | |
os.path.expanduser('~/Library/Application Support/Steam/Steam.AppBundle/Steam'), # Steam | |
] | |
# The extension of an icon file | |
ICON_EXTENSION = '.icns' | |
# The extension of an application directory | |
APPLICATION_EXTENSION = '.app' | |
# Colors to use for coloring the console output | |
COLOR_RED = "\033[1;31m" | |
COLOR_YELLOW = "\033[1;33m" | |
COLOR_CYAN = "\033[1;36m" | |
COLOR_GREEN = "\033[0;32m" | |
COLOR_RESET = "\033[0;0m" | |
def color(input_string: str, color_string: str) -> str: | |
""" | |
Color the input with the given color and reset afterwards | |
:param input_string: The input to return with the given color | |
:param color_string: The color to use | |
:return: The input prefixed with the color and suffixed with a reset | |
""" | |
return "{color}{input}{reset}".format( | |
input=input_string, | |
color=color_string, | |
reset=COLOR_RESET | |
) | |
def is_root() -> bool: | |
""" | |
Check if this script is running as root | |
:return: True if the script is running as root | |
""" | |
return os.getuid() == 0 | |
def get_icons(directory: str) -> List[str]: | |
""" | |
Get a list of files that have the `.icns` extension in the given directory | |
:param directory: The directory to search | |
:return: A list of paths that are icons | |
""" | |
return glob.glob('{0}/*{1}'.format(directory, ICON_EXTENSION)) | |
def get_applications_from_directory(directory: str) -> List[str]: | |
""" | |
Search for applications in the given directory. | |
This also looks into any subdirectories recursively (with the exclusion for directories that are an application) | |
:param directory: The directory to look into | |
:return: A list of paths to applications | |
""" | |
if not os.path.exists(directory): | |
return [] | |
applications = [] | |
for name in os.listdir(directory): | |
# The full path to the item | |
item = os.path.join(directory, name) | |
if not os.path.isdir(item): | |
continue | |
if name.endswith(APPLICATION_EXTENSION): | |
applications.append(item) | |
continue | |
applications += get_applications_from_directory(item) | |
return applications | |
def find_applications(directories: List[str], applications: List[str]) -> List[str]: | |
""" | |
:param directories: The directories to search in for applications | |
:param applications: A list of additional applications that should be added | |
:return: A list of paths to directories with the `.app` suffix | |
""" | |
# Return a flattened list of all the applications | |
found_applications = [application for directory in directories for application in get_applications_from_directory(directory)] | |
# Add the additional applications | |
for application in applications: | |
if not os.path.isdir(application): | |
continue | |
found_applications.append(application) | |
return found_applications | |
def find_icon(application: str, icons: List[str]) -> Optional[str]: | |
""" | |
Find an icon that matches with the application | |
An icon matches as soon as the name of the icon is the same as the name of the application (with the exclusion of | |
the extensions) | |
:param application: A path to the application | |
:param icons: A list of paths to icons | |
:return: The path to the icon if an icon is found | |
""" | |
application_name = os.path.splitext(os.path.basename(application))[0] | |
for icon in icons: | |
icon_name = os.path.splitext(os.path.basename(icon))[0] | |
if icon_name == application_name: | |
return icon | |
return None | |
def patch_icon(application_path: str, icon_path: str): | |
""" | |
Patch the application with the given icon | |
:param application_path: The application to patch | |
:param icon_path: The icon to patch the application with | |
""" | |
NSWorkspace.sharedWorkspace().setIcon_forFile_options_( | |
NSImage.alloc().initWithContentsOfFile_(icon_path), | |
application_path, | |
0 | |
) | |
def kill_dock_and_finder(): | |
""" | |
Ask the user whether the dock and finder should be killed (and do so if answered by a yes). | |
Killing the dock and finder is required to refresh the icons that are currently shown there. | |
""" | |
while True: | |
should_kill = input( | |
"Would you like to refresh the icons by restarting the dock and Finder {prompt}? ".format( | |
prompt=color("(y/n)", COLOR_YELLOW) | |
)).lower().strip() | |
if should_kill == 'y': | |
os.system('killall Dock Finder') | |
break | |
if should_kill == 'n': | |
break | |
def main(): | |
if not is_root(): | |
sys.stderr.write(color("This script requires root privileges, rerun the script with sudo!\n", COLOR_RED)) | |
return | |
directory = sys.argv[1] if len(sys.argv) > 1 else os.getcwd() | |
icons = get_icons(directory) | |
print("Found {items} icon{plural} in {directory}".format( | |
items=color(str(len(icons)), COLOR_CYAN), | |
plural='' if len(icons) == 1 else 's', | |
directory=color(directory, COLOR_CYAN) | |
)) | |
applications = find_applications(APPLICATION_PATHS, APPLICATIONS) | |
print( | |
"Found {applications} application{applications_plural} in {directories} director{directories_plural}\n".format( | |
applications=color(str(len(applications)), COLOR_CYAN), | |
applications_plural='' if len(applications) == 1 else 's', | |
directories=color(str(len(APPLICATION_PATHS)), COLOR_CYAN), | |
directories_plural='y' if len(directory) == 1 else 'ies' | |
)) | |
for application in applications: | |
try: | |
icon = find_icon(application, icons) | |
if not icon: | |
continue | |
print("Patching {application} with icon {icon}".format( | |
application=color(application, COLOR_CYAN), | |
icon=color(os.path.basename(icon), COLOR_CYAN) | |
)) | |
patch_icon(application, icon) | |
except Exception as e: | |
sys.stderr.write(color("Could not patch {}: {}\n".format(application, e), COLOR_RED)) | |
print(color("\nDone patching icons!", COLOR_GREEN)) | |
kill_dock_and_finder() | |
print(color("\nDone", COLOR_GREEN)) | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment