Initial commit, version 0.1.2

This commit is contained in:
Timothy Sutton 2013-01-28 17:01:27 -05:00
parent 028e213d97
commit 1c6f739d8f
6 changed files with 380 additions and 3 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.DS_Store

16
CHANGELIST Normal file
View file

@ -0,0 +1,16 @@
0.1.2 (January 28, 2013)
- automatically change output dir to the drive root when the current working
directory is detected to be \Windows\system32, to resolve issue with system32/SysWoW64
and the BootCamp installer locating its packages
- support for setting alternate CatalogURL in brigadier.plist
- fixed trying to use 'rmdir' on both platforms for existing
download directory
- more readable status output
0.1.1 (January 23, 2013)
- '--leave-files' option added
- use rmdir for all Windows cleanup due to issue
with shutil and foreign characters
0.1.0 (January 11, 2013)
- first version

View file

@ -1,4 +1,62 @@
brigadier
=========
# Brigadier
Fetch and install Boot Camp ESDs with ease.
A Windows- and OS X-compatible Python script that fetches, from Apple's or your software update server, the Boot Camp ESD ("Electronic Software Distribution") for a specific model of Mac. It unpacks the multiple layers of archives within the flat package and if the script is run on Windows with the `--install` option, it also runs the 64-bit MSI installer. It currently _does not_ support installation on 32-bit Windows.
On Windows, the archives are unpacked using [7-Zip](http://www.7-zip.org) and [dmg2img](http://vu1tur.eu.org/tools), so these are downloaded and installed (and removed later) as needed. 7-Zip can convert .dmgs to ISOs as well, however I experienced inconsistent results across different currently-offered BootCampESD.pkgs, and haven't had any issues with dmg2img.
This was written for two reasons:
1. We'd like to maintain as few Windows system images as possible, but there are typically 3-5 BootCampESD packages available from Apple at any given time, targeting specific sets of models. It's possible to use the [Orca](http://support.microsoft.com/kb/255905) tool to edit the MSI's properties and disable the model check, but there are rarely cases where a single installer contains all drivers. Apple can already download the correct installer for a booted machine model in OS X using the Boot Camp Assistant, so there's no reason we can't do the same within Windows.
2. Sometimes we just want to download and extract a copy of the installer for a given model. The steps to do this manually are tedious, and there are many of them.
It was originally designed to be run as post-imaging step for Boot Camp deployments to Macs, but as it requires network connectivity, a network driver must be already available on the system. (See Caveats below)
## Windows requirements
You'll either need [Python for Windows](http://www.python.org/download/releases) (this was tested with the latest 2.7 release) in order to execute the script, or a tool like [PyInstaller](http://www.pyinstaller.org), which can compile the interpreter and script together into a single executable.
You can find a pre-compiled 32-bit binary (which will run on 64-bit Windows) of the most recent [release version](https://github.com/timsutton/brigadier/blob/master/VERSION) [here](https://dl.dropbox.com/u/429559/brigadier.zip) (sha1 189fa1b8f8267bb7bcb882e1becdc9e18c20f48a).
If you'd rather build it yourself using PyInstaller, install Python, PyInstaller and the appropriate version of [pywin32](http://sourceforge.net/projects/pywin32/files), then build a "single file deployment" with PyInstaller:
`%SystemRoot%\Python27\python \path\to\pyinstaller\pyinstaller.py -F \path\to\brigadier`
The resultant file should be in the `dist` folder created in the working directory.
## Configuration
Besides a few command-line options:
<pre><code>Usage: brigadier [options]
Options:
-h, --help show this help message and exit
-m MODEL, --model=MODEL
System model identifier to use (otherwise this
machine's model is used).
-i, --install After the installer is downloaded, perform the install
automatically. Can be used on Windows only.
-o OUTPUT_DIR, --output-dir=OUTPUT_DIR
Base path where the installer files will be extracted
into a folder named after the product, ie.
'BootCamp-041-1234'. Uses the current directory if
this option is omitted.
-l, --leave-files Leave the files that were downloaded/extracted. Useful
only with the '--install' option on Windows.</code></pre>
You can also create a `brigadier.plist` XML plist file and place it in the same directory as the script. It currently supports one key: `CatalogURL`, a string that points to an internal SUS catalog URL that contains BootCampESD packages. See the example [in this repo](https://github.com/timsutton/brigadier/blob/master/plist-example/brigadier.plist).
## Running as a Sysprep FirstLogonCommand
It's common to perform the Boot Camp drivers during a post-imaging Sysprep phase, so that it's possible to deploy the same image to different models without taking into account the model and required Boot Camp package. Brigadier seems to behave in the context of a SysPrep [FirstLogonCommand](http://technet.microsoft.com/en-us/library/cc722150(v=ws.10).aspx).
There is one workaround performed by the script when running in this scenario, where the current working would normally be `\windows\system32`. In my tests on a 64-bit system, the MSI would halt trying to locate its installer components, due to the way Windows forks its `System32` folder into `SysWoW64` for 32-bit applications. When the script detects this working directory without a `--output-dir` option overriding it, it will set the output directory to the root of the system, ie. `%SystemRoot%\`.
By default, when `--install` is used, it will clean up its extracted files after installation, unless the `--leave-files` option is given, so unless you want to keep the files around you shouldn't need to clean up after it.
## Caveats
* It requires an internet connection, which therefore requires that a working network driver be available. The simplest way I've found to do this is to place the various network drivers from BootCampESDs inside a "BootCamp" (or similar) folder within `C:\Windows\INF`. This folder is the default search location for device drivers, and it should automatically detect and install drivers located here for all unknown hardware. You can also modify the `DevicePath` [registry key](http://technet.microsoft.com/en-us/library/cc731664(v=ws.10).aspx) to add a custom location, but using the existing `INF` folder means no other changes besides a file copy are required to update an existing image's drivers, so this can be done without actually restoring the image and booting it just to install a driver.
* It currently performs almost no error handling.
* After installation, it sets the `FirstTimeRun` registry key at `HKEY_CURRENT_USER\Software\Apple Inc.\Apple Keyboard Support` to disable the first-launch Boot Camp help popup, and there's currently no option to disable this behaviour.
* Only tested on 64-bit Windows 7. It's worth mentioning that the December 2012 Boot Camp driver ESDs seem to be 64-bit only, so extra work would need to be done to support 32-bit Windows.

2
VERSION Normal file
View file

@ -0,0 +1,2 @@
0.1.2

292
brigadier Executable file
View file

@ -0,0 +1,292 @@
#!/usr/bin/python
import os
import sys
import subprocess
import urllib2
import plistlib
import re
import tempfile
import shutil
import optparse
from urllib import urlretrieve
from xml.dom import minidom
SUCATALOG_URL = 'http://swscan.apple.com/content/catalogs/others/index-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog'
# 7-Zip MSI (9.30 alpha)
SEVENZIP_URL = 'http://dl.7-zip.org/7z930-x64.msi'
# dmg2img zip download from http://vu1tur.eu.org/tools
DMG2IMG_URL = 'http://vu1tur.eu.org/tools/dmg2img-1.6.4-win32.zip'
def status(msg):
print "%s\n" % msg
def getCommandOutput(cmd):
p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
out, err = p.communicate()
return out
# Returns this machine's model identifier, using wmic on Windows,
# system_profiler on OS X
def getMachineModel():
if sys.platform == 'win32':
rawxml = getCommandOutput(['wmic', 'computersystem', 'get', 'model', '/format:RAWXML'])
dom = minidom.parseString(rawxml)
results = dom.getElementsByTagName("RESULTS")
nodes = results[0].getElementsByTagName("CIM")[0].getElementsByTagName("INSTANCE")[0]\
.getElementsByTagName("PROPERTY")[0].getElementsByTagName("VALUE")[0].childNodes
model = nodes[0].data
elif sys.platform == 'darwin':
plistxml = getCommandOutput(['system_profiler', 'SPHardwareDataType', '-xml'])
plist = plistlib.readPlistFromString(plistxml)
model = plist[0]['_items'][0]['machine_model']
return model
def getDmg2Img():
tempdir = tempfile.mkdtemp()
dmg2img_path = os.path.join(tempdir, DMG2IMG_URL.split('/')[-1])
urlretrieve(DMG2IMG_URL, filename=dmg2img_path)
sevenzipExtract(dmg2img_path)
dmg2img_exe = os.path.join(tempdir, 'dmg2img.exe')
if os.path.exists(dmg2img_exe):
return dmg2img_exe
else:
sys.exit("Can't find extracted dmg2img.exe")
def sevenzipExtract(arcfile, command='e', out_dir=None):
cmd = [os.path.join(os.environ['SYSTEMDRIVE'] + "\\", "Program Files", "7-Zip", "7z.exe")]
cmd.append(command)
if not out_dir:
out_dir = os.path.dirname(arcfile)
cmd.append("-o" + out_dir)
cmd.append("-y")
cmd.append(arcfile)
status("Calling 7-Zip command: %s" % ' '.join(cmd))
retcode = subprocess.call(cmd)
if retcode:
sys.exit("Command failure: %s exited %s." % (' '.join(cmd), retcode))
def postInstallConfig():
regdata = """Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Apple Inc.\Apple Keyboard Support]
"FirstTimeRun"=dword:00000000"""
handle, path = tempfile.mkstemp()
fd = os.fdopen(handle, 'w')
fd.write(regdata)
fd.close()
subprocess.call(['regedit.exe', '/s', path])
def findBootcampMSI(search_dir):
"""Returns the path of the 64-bit BootCamp MSI"""
bootcamp_dir = os.path.join(search_dir, 'Drivers', 'Apple')
# Most ESDs contain 'BootCamp.msi' and 'BootCamp64.msi'
# Dec. 2012 ESD contains only 'BootCamp.msi' which is 64-bit
candidates = ['BootCamp64.msi', 'BootCamp.msi']
for msi in candidates:
if os.path.exists(os.path.join(bootcamp_dir, msi)):
return os.path.join(bootcamp_dir, msi)
def installBootcamp(msipath):
logpath = os.path.abspath("/BootCamp_Install.log")
cmd = ['cmd', '/c', 'msiexec', '/i', msipath, '/qn', '/norestart', '/log', logpath]
status("Executing command: '%s'" % " ".join(cmd))
subprocess.call(cmd)
status("Install log output:")
with open(logpath, 'r') as logfd:
logdata = logfd.read()
print logdata.decode('utf-16')
postInstallConfig()
def main():
scriptdir = os.path.abspath(os.path.dirname(sys.argv[0]))
o = optparse.OptionParser()
o.add_option('-m', '--model', help="System model identifier to use \
(otherwise this machine's model is used).")
o.add_option('-i', '--install', action="store_true",
help="After the installer is downloaded, perform the install automatically. \
Can be used on Windows only.")
o.add_option('-o', '--output-dir',
help="Base path where the installer files will be extracted into a folder named after the \
product, ie. 'BootCamp-041-1234'. Uses the current directory if this option is omitted.")
o.add_option('-l', '--leave-files', action="store_true",
help="Leave the files that were downloaded/extracted. Useful only with the \
'--install' option on Windows.")
opts, args = o.parse_args()
if opts.install:
if sys.platform == 'darwin':
sys.exit("Installing Boot Camp can only be done on Windows!")
if sys.platform == 'win32' and os.environ['PROCESSOR_ARCHITECTURE'] != 'AMD64':
sys.exit("Installing on anything other than 64-bit Windows is currently not supported!")
if opts.output_dir:
if not os.path.isdir(opts.output_dir):
sys.exit("Output directory %s that was specified doesn't exist!" % opts.output_dir)
if not os.access(opts.output_dir, os.W_OK):
sys.exit("Output directory %s is not writable by this user!" % opts.output_dir)
output_dir = opts.output_dir
else:
output_dir = os.getcwd()
if output_dir.endswith('ystem32'):
output_dir = os.environ['SystemDrive'] + "\\"
status("Changing output directory to %s to work around an issue \
when running the installer out of 'system32'." % output_dir)
if opts.leave_files and not opts.install:
sys.exit("The --leave-files option is only useful when used with --install option!")
if opts.model:
model = opts.model
else:
model = getMachineModel()
status("Using Mac model '%s'." % model)
sucatalog_url = SUCATALOG_URL
# check if we defined anything in brigadier.plist
config_plist = None
plist_path = os.path.join(scriptdir, 'brigadier.plist')
if os.path.isfile(plist_path):
try:
config_plist = plistlib.readPlist(plist_path)
except:
status("Config plist was found at %s but it could not be read. \
Verify that it is readable and is an XML formatted plist." % plist_path)
if config_plist:
if 'CatalogURL' in config_plist.keys():
sucatalog_url = config_plist['CatalogURL']
urlfd = urllib2.urlopen(sucatalog_url)
data = urlfd.read()
p = plistlib.readPlistFromString(data)
allprods = p['Products']
# Get all Boot Camp ESD products
bc_prods = []
for (prod_id, prod_data) in allprods.items():
if 'ServerMetadataURL' in prod_data.keys():
bc_match = re.search('BootCamp', prod_data['ServerMetadataURL'])
if bc_match:
bc_prods.append((prod_id, prod_data))
# Find the ESD(s) that applies to our model
pkg_data = []
supported_models = []
re_model = "([a-zA-Z]{4,12}[1-9]{1,2}\,[1-6])"
for bc_prod in bc_prods:
if 'English' in bc_prod[1]['Distributions'].keys():
disturl = bc_prod[1]['Distributions']['English']
distfd = urllib2.urlopen(disturl)
dist_data = distfd.read()
if re.search(model, dist_data):
pkg_data.append({bc_prod[0]: bc_prod[1]})
model_matches_in_dist = re.findall(re_model, dist_data)
for supported_model in model_matches_in_dist:
supported_models.append(supported_model)
status("Model supported in package distribution file at %s." % disturl)
status("Distribution supports the following models: %s." % ', '.join(supported_models))
# Ensure we have only one ESD
if len(pkg_data) > 1:
sys.exit("There was more than one SUS pkg available (this should never happen, \
but it's possible if you're using your own SUS and you have both old and current ESDs for \
the same model): %s" % pkg_data.join(", "))
if len(pkg_data) == 0:
sys.exit("Couldn't find a Boot Camp ESD for the model %s in the given software update catalog." % model)
pkg_data = pkg_data[0]
pkg_id = pkg_data.keys()[0]
pkg_url = pkg_data.values()[0]['Packages'][0]['URL']
# make a sub-dir in the output_dir here, named by product
landing_dir = os.path.join(output_dir, 'BootCamp-' + pkg_id)
if os.path.exists(landing_dir):
status("Final output path %s already exists, removing it..." % landing_dir)
if sys.platform == 'win32':
# using rmdir /qs because shutil.rmtree dies on the Doc files with foreign language characters
subprocess.call(['cmd', '/c', 'rmdir', '/q', '/s', landing_dir])
else:
shutil.rmtree(landing_dir)
status("Making directory %s.." % landing_dir)
os.mkdir(landing_dir)
arc_workdir = tempfile.mkdtemp(prefix="bootcamp-unpack_")
pkg_dl_path = os.path.join(arc_workdir, pkg_url.split('/')[-1])
status("Fetching Boot Camp product at URL %s." % pkg_url)
urlretrieve(pkg_url, filename=pkg_dl_path)
if sys.platform == 'win32':
we_installed_7zip = False
sevenzip_binary = os.path.join(os.environ['SYSTEMDRIVE'] + "\\", 'Program Files', '7-Zip', '7z.exe')
# fetch and install 7-Zip
if not os.path.exists(sevenzip_binary):
tempdir = tempfile.mkdtemp()
sevenzip_msi_dl_path = os.path.join(tempdir, SEVENZIP_URL.split('/')[-1])
urlretrieve(SEVENZIP_URL, filename=sevenzip_msi_dl_path)
status("Downloaded 7-zip to %s." % sevenzip_msi_dl_path)
status("We need to install 7-Zip..")
retcode = subprocess.call(['msiexec', '/qn', '/i', sevenzip_msi_dl_path])
status("7-Zip install returned exit code %s." % retcode)
we_installed_7zip = True
status("Extracting...")
# BootCamp.pkg (xar) -> Payload (gzip) -> Payload~ (cpio) -> WindowsSupport.dmg
for arc in [pkg_dl_path,
os.path.join(arc_workdir, 'Payload'),
os.path.join(arc_workdir, 'Payload~')]:
sevenzipExtract(arc)
dmg2iso_path = getDmg2Img()
dmg_extract_cmd = [dmg2iso_path, '-v',
os.path.join(arc_workdir, 'WindowsSupport.dmg'),
os.path.join(arc_workdir, 'WindowsSupport.iso')]
subprocess.call(dmg_extract_cmd)
sevenzipExtract(os.path.join(arc_workdir, 'WindowsSupport.iso'),
command='x',
out_dir=landing_dir)
if we_installed_7zip:
status("Cleaning up the 7-Zip install...")
subprocess.call(['cmd', '/c', 'msiexec', '/qn', '/x', sevenzip_msi_dl_path])
if opts.install:
status("Installing Boot Camp...")
installBootcamp(findBootcampMSI(landing_dir))
if not opts.leave_files:
subprocess.call(['cmd', '/c', 'rmdir', '/q', '/s', landing_dir])
# clean up the temp dir always
subprocess.call(['cmd', '/c', 'rmdir', '/q', '/s', arc_workdir])
elif sys.platform == 'darwin':
status("Expanding flat package...")
subprocess.call(['/usr/sbin/pkgutil', '--expand', pkg_dl_path,
os.path.join(arc_workdir, 'pkg')])
status("Extracting Payload...")
subprocess.call(['/usr/bin/tar', '-xz', '-C', arc_workdir, '-f', os.path.join(arc_workdir, 'pkg', 'Payload')])
output_file = os.path.join(landing_dir, 'WindowsSupport.dmg')
shutil.move(os.path.join(arc_workdir, 'Library/Application Support/BootCamp/WindowsSupport.dmg'),
output_file)
status("Extracted to %s." % output_file)
# If we were to also copy out the contents from the .dmg we might do it like this, but if you're doing this
# from OS X you probably would rather just burn a disc so we'll stop here..
# mountxml = getCommandOutput(['/usr/bin/hdiutil', 'attach',
# os.path.join(arc_workdir, 'Library/Application Support/BootCamp/WindowsSupport.dmg'),
# '-mountrandom', '/tmp', '-plist', '-nobrowse'])
# mountplist = plistlib.readPlistFromString(mountxml)
# mntpoint = mountplist['system-entities'][0]['mount-point']
# shutil.copytree(mntpoint, output_dir)
# subprocess.call(['/usr/bin/hdiutil', 'eject', mntpoint])
shutil.rmtree(arc_workdir)
status("Done.")
if __name__ == "__main__":
main()

8
plist-example/brigadier.plist Executable file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CatalogURL</key>
<string>http://my.org/content/catalogs/others/index-mountainlion-lion-snowleopard-leopard.merged-1_release.sucatalog</string>
</dict>
</plist>