Skip to content

Instantly share code, notes, and snippets.

@jord-nijhuis
Last active September 7, 2023 19:58
Show Gist options
  • Save jord-nijhuis/04cb2f0d9c71e82b2b1e1d614794793d to your computer and use it in GitHub Desktop.
Save jord-nijhuis/04cb2f0d9c71e82b2b1e1d614794793d to your computer and use it in GitHub Desktop.
[MacOS] Patch application icons
#!/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