Google Chrome: Autoupdating accumulates older versions - how to delete

mthakur
Contributor

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.

7 REPLIES 7

marklamont
Contributor III

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.

echalupka
New Contributor III

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

mthakur
Contributor

@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())

mthakur
Contributor

@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

echalupka
New Contributor III

Thanks a bunch @mthakur !

dragon788
New Contributor

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())

 

dragon788
New Contributor

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.