Skip to main content

Unlike on Windows, if you've enabled Google Chrome for Mac to autoupdate for all users, over time you'll start to see an accumulation of old versions:



$ pwd
/Applications/Google Chrome.app/Contents/Versions
$ ls
65.0.3325.181 66.0.3359.170 67.0.3396.87 67.0.3396.99
$


(Again, these only appear if you've configured Google Chrome to autoupdate.)



Despite extensive searching online (Chrome's online documentation, Chrome's source code on chromium.org), I could not find any built-in mechanism in Google Chrome for Mac to clean out these old versions.



I did find a Google build tool (clean_up_old_versions.py), and modified it for my needs, while also borrowing some code from Hannes Juutilainen's excellent chrome-enable-autoupdates.py.



The resulting script now correctly detects the current version installed on disk, leaves it intact, and deletes all other versions installed inside the /Applications/Google Chrome.app/Contents/Versions folder:



$ sudo /tmp/clean_up_old_chrome_versions.py
Password:
Removing /Applications/Google Chrome.app/Contents/Versions/65.0.3325.181
Removing /Applications/Google Chrome.app/Contents/Versions/66.0.3359.170
Removing /Applications/Google Chrome.app/Contents/Versions/67.0.3396.87
$ pwd
/Applications/Google Chrome.app/Contents/Versions
$ ls
67.0.3396.99
$


The downside to having a separate script is that it isn't run automatically after Keystone updates Google Chrome, i.e. the script has to be run separately and periodically via a separate JAMF Pro policy.



Also, some enterprise sites may have other Google products installed (e.g. Google Earth) that are also under Keystone management, and modifying this script (or creating yet another version of it) soon becomes an unwieldy approach.



So... my questions:




  1. Does anyone know of a built-in mechanism in Google Chrome for Mac to clean out these older versions, similar to what exists for Google Chrome for Windows?

  2. Does anyone know where to find the source code for Google's Keystone update subsystem? (This doesn't appear to be available on chromium.org — I looked.)

  3. Is this a known issue in the Chromium bug database, and is the Chromium team working on fixing it? (I tried to search through the Chromium bug database, but it's huge and unclear how to search for Mac-specific bugs, and I just ran out of steam checking it....)



Ideally, Google would add support to Google Chrome for Mac (and other Google software for Mac) to clean old versions automatically, immediately after each update is installed. A feature request may need to be submitted to Google.

we created an EA that records the number of versions (i.e. count the folders) then have a policy that simply deletes the entire folder and reinstall Chrome direct from Google. I think it's based on this script.


Could you post your script on here? We're seeing the same issue occur over here.


@echalupka
Certainly. Here you go:



#!/usr/bin/env python

# clean_chrome_versions.py
#
# Manavendra Thakur
# International Monetary Fund
# mthakur@imf.org
#
# v1.0
# 2018-07-05

# ChangeLog
# v1.0 2018-07-05
# Initial version

# Credits

# Derived from
# https://cs.chromium.org/chromium/src/chrome/tools/build/mac/clean_up_old_versions.py
# Retained copyright and license notice:
# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

# Code portions borrowed from:
# chrome-enable-autoupdates.py
# https://github.com/hjuutilainen/adminscripts
# Created by Hannes Juutilainen, hjuutilainen@mac.com

import optparse
import os
import shutil
import sys
import plistlib
import pwd
import stat

default_chrome_path = "/Applications/Google Chrome.app"
default_umask = 0o022


def clean_app_versions(path_to_app, app_keep_version):
"""Delete all Google Chrome versions except keep version"""
versions_dir = os.path.join(path_to_app, 'Contents', 'Versions')
if os.path.exists(versions_dir):
for version in os.listdir(versions_dir):
if version != app_keep_version:
path_to_delete = os.path.join(versions_dir, version)
print "Removing %s" % (path_to_delete)
shutil.rmtree(path_to_delete)
else:
print >> sys.stderr, "Error: Path %s does not exist." % (versions_dir)
return 1

def chrome_installed(chrome_path):
"""Check if Chrome is installed"""
if os.path.exists(chrome_path):
return True
else:
return False

def installed_chrome_version(chrome_path):
"""Returns Chrome version"""
info_plist_path = os.path.realpath(os.path.join(chrome_path, 'Contents/Info.plist'))
info_plist = plistlib.readPlist(info_plist_path)
bundle_short_version = info_plist["CFBundleShortVersionString"]
return bundle_short_version

def clean_user_level_keystone_updates(disable_current_updates, block_future_updates, umask):
"""Disables Google Keystone in user home folders"""
# For each user on computer...
for p in pwd.getpwall():
user_id = p.pw_uid
group_id = p.pw_gid
user_home_dir = p.pw_dir
# Exclude system accounts
if user_home_dir.startswith("/var/") or user_home_dir.startswith("/private/") or user_home_dir.startswith("/Library/"):
continue
google_dir = os.path.join(user_home_dir, "Library/Google")
googlesoftwareupdate_dir = os.path.join(google_dir, "GoogleSoftwareUpdate")
# If ~/Library/Google/GoogleSoftwareUpdate exists...
if os.path.exists(googlesoftwareupdate_dir):
flags = os.stat(googlesoftwareupdate_dir).st_flags
# Make no changes if folder is locked, even though sudo user can override
mutable = not bool(flags & stat.UF_IMMUTABLE)
if mutable:
if disable_current_updates:
# Delete contents of ~/Library/Google/GoogleSoftwareUpdate
for filename in os.listdir(googlesoftwareupdate_dir):
filepath = os.path.join(googlesoftwareupdate_dir, filename)
try:
shutil.rmtree(filepath)
except OSError:
os.remove(filepath)
if block_future_updates:
# Lock folder to prevent future Keystone update attempts
os.chflags(googlesoftwareupdate_dir, flags | stat.UF_IMMUTABLE)
else:
# ~/Library/Google/GoogleSoftwareUpdate does not exist
if block_future_updates:
# Create ~/Library/Google/GoogleSoftwareUpdate
old_umask = os.umask(umask)
if os.path.exists(google_dir):
os.mkdir(googlesoftwareupdate_dir)
os.chown(googlesoftwareupdate_dir, user_id, group_id)
else:
os.makedirs(googlesoftwareupdate_dir)
os.chown(google_dir, user_id, group_id)
os.chown(googlesoftwareupdate_dir, user_id, group_id)
# Lock folder to prevent future Keystone update attempts
flags = os.stat(googlesoftwareupdate_dir).st_flags
os.chflags(googlesoftwareupdate_dir, flags | stat.UF_IMMUTABLE)
os.umask(old_umask)

def Main():
"""The main routine."""
Usage = """%prog [options] [Chrome path] [keep version]

Deletes non-current (both older and newer) versions of Google
Chrome that can accumulate in the /Applications/Google
Chrome.app/Contents/Versions folder over time if Google Chrome
auto-update is enabled for all users.

Optional Arguments: Specify path to Google Chrome app and
version to keep intact.
Defaults to "/Applications/Google Chrome.app" and installed
version.

This script must be run with admin privilegs.

Examples:

Clean Chrome versions from "/Applications/Google
Chrome.app/Contents/Versions" folder:
sudo clean_chrome_versions.py

Same as above, and delete current Keystone updates, and block
future Keystone updates:
sudo clean_chrome_versions.py -a

Same as above, but with umask of 077 (i.e. "drwx------"
permissions on any created folders):
sudo clean_chrome_versions.py -a -u 077

Specify nonstandard path and specific version to keep:
sudo clean_chrome_versions.py "/Path/To/Google Chrome.app" 1.2.3.4
"""

parser = optparse.OptionParser(usage=Usage)
parser.add_option('-a', '--all', action='store_true', help=("Disable both current and future Google Keystone updates for existing users, except system accounts. Same as specifying -d and -b."))
parser.add_option('-d', '--disable-current', action='store_true', help=("Disable current Google Keystone updates for existing users, except system accounts, by deleting contents of ~/Library/Google/GoogleSoftwareUpdate."))
parser.add_option('-b', '--block-future', action='store_true', help=(
"Block future Google Keystone updates for existing users, except system accounts, by locking the ~/Library/Google/GoogleSoftwareUpdate folder, creating this folder if it doesn't exist."))
parser.add_option('-u', '--umask', type="int", help=(
"Umask used while creating Keystone update folders. Defaults to %s." % oct(default_umask)))
opts, args = parser.parse_args()
if len(args) != 2:
chrome_path = default_chrome_path
keep_version = installed_chrome_version(chrome_path)
else:
chrome_path = args[0]
keep_version = args[1]

all = opts.all or False
disable_current = opts.disable_current or all or False
block_future = opts.block_future or all or False
umask = opts.umask or default_umask

# Check for root
if os.geteuid() != 0:
print >> sys.stderr, "Error: This script must be run as root"
return 1

if not chrome_installed(chrome_path):
print >> sys.stderr, "Error: Chrome path %s does not exist." % (chrome_path)
return 1

clean_app_versions(chrome_path, keep_version)
if disable_current or block_future:
clean_user_level_keystone_updates(disable_current, block_future, umask)
return 0

if __name__ == '__main__':
sys.exit(Main())

@echalupka
Please invoke the code from the command line as follows:



Clean Chrome versions from "/Applications/Google Chrome.app/Contents/Versions" folder:
$ sudo clean_chrome_versions.py



Same as above, and delete current Keystone updates, and block future Keystone updates:
$ sudo clean_chrome_versions.py -a



Same as above, but with umask of 077 (i.e. "drwx------" permissions on any created folders):
$ sudo clean_chrome_versions.py -a -u 077



Specify nonstandard path and specific version to keep:
$ sudo clean_chrome_versions.py "/Path/To/Google Chrome.app" 1.2.3.4


Thanks a bunch @mthakur !


I had to make a few changes to get this working on macOS Big Sur 11.5.x with modern versions of Chrome (test with 91.x+).

 

 

diff --git a/clean_chrome_versions.py b/clean_chrome_versions.py
index f23533d..9d5fc86 100755
--- a/clean_chrome_versions.py
+++ b/clean_chrome_versions.py
@@ -41,15 +41,19 @@ default_umask = 0o022

def clean_app_versions(path_to_app, app_keep_version):
"""Delete all Google Chrome versions except keep version"""
- versions_dir = os.path.join(path_to_app, 'Contents', 'Versions')
+ new_versions_path = '/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions/'
+ versions_dir = os.path.join(new_versions_path)
if os.path.exists(versions_dir):
+ # Detect which version Current symlink points to and don't try to rm Current or that version
for version in os.listdir(versions_dir):
- if version != app_keep_version:
+ if version == 'Current':
+ next
+ elif version != app_keep_version:
path_to_delete = os.path.join(versions_dir, version)
- print "Removing %s" % (path_to_delete)
+ print("Removing %s" % (path_to_delete))
shutil.rmtree(path_to_delete)
else:
- print >> sys.stderr, "Error: Path %s does not exist." % (versions_dir)
+ sys.stderr.write("Error: Path %s does not exist." % (versions_dir))
return 1

def chrome_installed(chrome_path):
@@ -62,9 +66,10 @@ def chrome_installed(chrome_path):
def installed_chrome_version(chrome_path):
"""Returns Chrome version"""
info_plist_path = os.path.realpath(os.path.join(chrome_path, 'Contents/Info.plist'))
- info_plist = plistlib.readPlist(info_plist_path)
- bundle_short_version = info_plist["CFBundleShortVersionString"]
- return bundle_short_version
+ with open(info_plist_path, 'rb') as plist:
+ info_plist = plistlib.load(plist)
+ bundle_short_version = info_plist["CFBundleShortVersionString"]
+ return bundle_short_version

def clean_user_level_keystone_updates(disable_current_updates, block_future_updates, umask):
"""Disables Google Keystone in user home folders"""
@@ -168,11 +173,11 @@ def Main():

# Check for root
if os.geteuid() != 0:
- print >> sys.stderr, "Error: This script must be run as root"
+ sys.stderr.write("Error: This script must be run as root")
return 1

if not chrome_installed(chrome_path):
- print >> sys.stderr, "Error: Chrome path %s does not exist." % (chrome_path)
+ sys.stderr.write("Error: Chrome path %s does not exist." % (chrome_path))
return 1

clean_app_versions(chrome_path, keep_version)

 

 

Full updated version:

 

#!/usr/bin/env python

# clean_chrome_versions.py
#
# Manavendra Thakur
# International Monetary Fund
# mthakur@imf.org
#
# v1.0
# 2018-07-05

# ChangeLog
# v1.0 2018-07-05
# Initial version

# Credits

# Derived from
# https://cs.chromium.org/chromium/src/chrome/tools/build/mac/clean_up_old_versions.py
# Retained copyright and license notice:
# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

# Code portions borrowed from:
# chrome-enable-autoupdates.py
# https://github.com/hjuutilainen/adminscripts
# Created by Hannes Juutilainen, hjuutilainen@mac.com

import optparse
import os
import shutil
import sys
import plistlib
import pwd
import stat

default_chrome_path = "/Applications/Google Chrome.app"
default_umask = 0o022


def clean_app_versions(path_to_app, app_keep_version):
"""Delete all Google Chrome versions except keep version"""
new_versions_path = '/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions/'
versions_dir = os.path.join(path_to_app, new_versions_path)
if os.path.exists(versions_dir):
# Detect which version Current symlink points to and don't try to rm Current or that version
for version in os.listdir(versions_dir):
if version == 'Current':
next
elif version != app_keep_version:
path_to_delete = os.path.join(versions_dir, version)
print("Removing %s" % (path_to_delete))
shutil.rmtree(path_to_delete)
else:
sys.stderr.write("Error: Path %s does not exist." % (versions_dir))
return 1

def chrome_installed(chrome_path):
"""Check if Chrome is installed"""
if os.path.exists(chrome_path):
return True
else:
return False

def installed_chrome_version(chrome_path):
"""Returns Chrome version"""
info_plist_path = os.path.realpath(os.path.join(chrome_path, 'Contents/Info.plist'))
with open(info_plist_path, 'rb') as plist:
info_plist = plistlib.load(plist)
bundle_short_version = info_plist["CFBundleShortVersionString"]
return bundle_short_version

def clean_user_level_keystone_updates(disable_current_updates, block_future_updates, umask):
"""Disables Google Keystone in user home folders"""
# For each user on computer...
for p in pwd.getpwall():
user_id = p.pw_uid
group_id = p.pw_gid
user_home_dir = p.pw_dir
# Exclude system accounts
if user_home_dir.startswith("/var/") or user_home_dir.startswith("/private/") or user_home_dir.startswith("/Library/"):
continue
google_dir = os.path.join(user_home_dir, "Library/Google")
googlesoftwareupdate_dir = os.path.join(google_dir, "GoogleSoftwareUpdate")
# If ~/Library/Google/GoogleSoftwareUpdate exists...
if os.path.exists(googlesoftwareupdate_dir):
flags = os.stat(googlesoftwareupdate_dir).st_flags
# Make no changes if folder is locked, even though sudo user can override
mutable = not bool(flags & stat.UF_IMMUTABLE)
if mutable:
if disable_current_updates:
# Delete contents of ~/Library/Google/GoogleSoftwareUpdate
for filename in os.listdir(googlesoftwareupdate_dir):
filepath = os.path.join(googlesoftwareupdate_dir, filename)
try:
shutil.rmtree(filepath)
except OSError:
os.remove(filepath)
if block_future_updates:
# Lock folder to prevent future Keystone update attempts
os.chflags(googlesoftwareupdate_dir, flags | stat.UF_IMMUTABLE)
else:
# ~/Library/Google/GoogleSoftwareUpdate does not exist
if block_future_updates:
# Create ~/Library/Google/GoogleSoftwareUpdate
old_umask = os.umask(umask)
if os.path.exists(google_dir):
os.mkdir(googlesoftwareupdate_dir)
os.chown(googlesoftwareupdate_dir, user_id, group_id)
else:
os.makedirs(googlesoftwareupdate_dir)
os.chown(google_dir, user_id, group_id)
os.chown(googlesoftwareupdate_dir, user_id, group_id)
# Lock folder to prevent future Keystone update attempts
flags = os.stat(googlesoftwareupdate_dir).st_flags
os.chflags(googlesoftwareupdate_dir, flags | stat.UF_IMMUTABLE)
os.umask(old_umask)

def Main():
"""The main routine."""
Usage = """%prog [options] [Chrome path] [keep version]

Deletes non-current (both older and newer) versions of Google
Chrome that can accumulate in the /Applications/Google
Chrome.app/Contents/Versions folder over time if Google Chrome
auto-update is enabled for all users.

Optional Arguments: Specify path to Google Chrome app and
version to keep intact.
Defaults to "/Applications/Google Chrome.app" and installed
version.

This script must be run with admin privilegs.

Examples:

Clean Chrome versions from "/Applications/Google
Chrome.app/Contents/Versions" folder:
sudo clean_chrome_versions.py

Same as above, and delete current Keystone updates, and block
future Keystone updates:
sudo clean_chrome_versions.py -a

Same as above, but with umask of 077 (i.e. "drwx------"
permissions on any created folders):
sudo clean_chrome_versions.py -a -u 077

Specify nonstandard path and specific version to keep:
sudo clean_chrome_versions.py "/Path/To/Google Chrome.app" 1.2.3.4
"""

parser = optparse.OptionParser(usage=Usage)
parser.add_option('-a', '--all', action='store_true', help=("Disable both current and future Google Keystone updates for existing users, except system accounts. Same as specifying -d and -b."))
parser.add_option('-d', '--disable-current', action='store_true', help=("Disable current Google Keystone updates for existing users, except system accounts, by deleting contents of ~/Library/Google/GoogleSoftwareUpdate."))
parser.add_option('-b', '--block-future', action='store_true', help=(
"Block future Google Keystone updates for existing users, except system accounts, by locking the ~/Library/Google/GoogleSoftwareUpdate folder, creating this folder if it doesn't exist."))
parser.add_option('-u', '--umask', type="int", help=(
"Umask used while creating Keystone update folders. Defaults to %s." % oct(default_umask)))
opts, args = parser.parse_args()
if len(args) != 2:
chrome_path = default_chrome_path
keep_version = installed_chrome_version(chrome_path)
else:
chrome_path = args[0]
keep_version = args[1]

all = opts.all or False
disable_current = opts.disable_current or all or False
block_future = opts.block_future or all or False
umask = opts.umask or default_umask

# Check for root
if os.geteuid() != 0:
sys.stderr.write("Error: This script must be run as root")
return 1

if not chrome_installed(chrome_path):
sys.stderr.write("Error: Chrome path %s does not exist." % (chrome_path))
return 1

clean_app_versions(chrome_path, keep_version)
if disable_current or block_future:
clean_user_level_keystone_updates(disable_current, block_future, umask)
return 0

if __name__ == '__main__':
sys.exit(Main())

 


Silly reply submission making me do a duplicate post since the first one didn't show up, and then the second didn't either for quite some time.