Home | History | Annotate | Download | only in tools
      1 #!/usr/bin/env python
      2 # Copyright (c) 2012 The Chromium Authors. All rights reserved.
      3 # Use of this source code is governed by a BSD-style license that can be
      4 # found in the LICENSE file.
      5 
      6 """Generates .msi from a .zip archive or an unpacked directory.
      7 
      8 The structure of the input archive or directory should look like this:
      9 
     10   +- archive.zip
     11      +- archive
     12         +- parameters.json
     13 
     14 The name of the archive and the top level directory in the archive must match.
     15 When an unpacked directory is used as the input "archive.zip/archive" should
     16 be passed via the command line.
     17 
     18 'parameters.json' specifies the parameters to be passed to candle/light and
     19 must have the following structure:
     20 
     21   {
     22     "defines": { "name": "value" },
     23     "extensions": [ "WixFirewallExtension.dll" ],
     24     "switches": [ '-nologo' ],
     25     "source": "chromoting.wxs",
     26     "bind_path": "files",
     27     "sign": [ ... ],
     28     "candle": { ... },
     29     "light": { ... }
     30   }
     31 
     32 "source" specifies the name of the input .wxs relative to
     33     "archive.zip/archive".
     34 "bind_path" specifies the path where to look for binary files referenced by
     35     .wxs relative to "archive.zip/archive".
     36 
     37 This script is used for both building Chromoting Host installation during
     38 Chromuim build and for signing Chromoting Host installation later. There are two
     39 copies of this script because of that:
     40 
     41   - one in Chromium tree at src/remoting/tools/zip2msi.py.
     42   - another one next to the signing scripts.
     43 
     44 The copies of the script can be out of sync so make sure that a newer version is
     45 compatible with the older ones when updating the script.
     46 """
     47 
     48 import copy
     49 import json
     50 from optparse import OptionParser
     51 import os
     52 import re
     53 import subprocess
     54 import sys
     55 import zipfile
     56 
     57 
     58 def UnpackZip(target, source):
     59   """Unpacks |source| archive to |target| directory."""
     60   target = os.path.normpath(target)
     61   archive = zipfile.ZipFile(source, 'r')
     62   for f in archive.namelist():
     63     target_file = os.path.normpath(os.path.join(target, f))
     64     # Sanity check to make sure .zip uses relative paths.
     65     if os.path.commonprefix([target_file, target]) != target:
     66       print "Failed to unpack '%s': '%s' is not under '%s'" % (
     67           source, target_file, target)
     68       return 1
     69 
     70     # Create intermediate directories.
     71     target_dir = os.path.dirname(target_file)
     72     if not os.path.exists(target_dir):
     73       os.makedirs(target_dir)
     74 
     75     archive.extract(f, target)
     76   return 0
     77 
     78 
     79 def Merge(left, right):
     80   """Merges two values.
     81 
     82   Raises:
     83     TypeError: |left| and |right| cannot be merged.
     84 
     85   Returns:
     86     - if both |left| and |right| are dictionaries, they are merged recursively.
     87     - if both |left| and |right| are lists, the result is a list containing
     88         elements from both lists.
     89     - if both |left| and |right| are simple value, |right| is returned.
     90     - |TypeError| exception is raised if a dictionary or a list are merged with
     91         a non-dictionary or non-list correspondingly.
     92   """
     93   if isinstance(left, dict):
     94     if isinstance(right, dict):
     95       retval = copy.copy(left)
     96       for key, value in right.iteritems():
     97         if key in retval:
     98           retval[key] = Merge(retval[key], value)
     99         else:
    100           retval[key] = value
    101       return retval
    102     else:
    103       raise TypeError('Error: merging a dictionary and non-dictionary value')
    104   elif isinstance(left, list):
    105     if isinstance(right, list):
    106       return left + right
    107     else:
    108       raise TypeError('Error: merging a list and non-list value')
    109   else:
    110     if isinstance(right, dict):
    111       raise TypeError('Error: merging a dictionary and non-dictionary value')
    112     elif isinstance(right, list):
    113       raise TypeError('Error: merging a dictionary and non-dictionary value')
    114     else:
    115       return right
    116 
    117 quote_matcher_regex = re.compile(r'\s|"')
    118 quote_replacer_regex = re.compile(r'(\\*)"')
    119 
    120 
    121 def QuoteArgument(arg):
    122   """Escapes a Windows command-line argument.
    123 
    124   So that the Win32 CommandLineToArgv function will turn the escaped result back
    125   into the original string.
    126   See http://msdn.microsoft.com/en-us/library/17w5ykft.aspx
    127   ("Parsing C++ Command-Line Arguments") to understand why we have to do
    128   this.
    129 
    130   Args:
    131       arg: the string to be escaped.
    132   Returns:
    133       the escaped string.
    134   """
    135 
    136   def _Replace(match):
    137     # For a literal quote, CommandLineToArgv requires an odd number of
    138     # backslashes preceding it, and it produces half as many literal backslashes
    139     # (rounded down). So we need to produce 2n+1 backslashes.
    140     return 2 * match.group(1) + '\\"'
    141 
    142   if re.search(quote_matcher_regex, arg):
    143     # Escape all quotes so that they are interpreted literally.
    144     arg = quote_replacer_regex.sub(_Replace, arg)
    145     # Now add unescaped quotes so that any whitespace is interpreted literally.
    146     return '"' + arg + '"'
    147   else:
    148     return arg
    149 
    150 
    151 def GenerateCommandLine(tool, source, dest, parameters):
    152   """Generates the command line for |tool|."""
    153   # Merge/apply tool-specific parameters
    154   params = copy.copy(parameters)
    155   if tool in parameters:
    156     params = Merge(params, params[tool])
    157 
    158   wix_path = os.path.normpath(params.get('wix_path', ''))
    159   switches = [os.path.join(wix_path, tool), '-nologo']
    160 
    161   # Append the list of defines and extensions to the command line switches.
    162   for name, value in params.get('defines', {}).iteritems():
    163     switches.append('-d%s=%s' % (name, value))
    164 
    165   for ext in params.get('extensions', []):
    166     switches += ('-ext', os.path.join(wix_path, ext))
    167 
    168   # Append raw switches
    169   switches += params.get('switches', [])
    170 
    171   # Append the input and output files
    172   switches += ('-out', dest, source)
    173 
    174   # Generate the actual command line
    175   #return ' '.join(map(QuoteArgument, switches))
    176   return switches
    177 
    178 
    179 def Run(args):
    180   """Runs a command interpreting the passed |args| as a command line."""
    181   command = ' '.join(map(QuoteArgument, args))
    182   popen = subprocess.Popen(
    183       command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    184   out, _ = popen.communicate()
    185   if popen.returncode:
    186     print command
    187     for line in out.splitlines():
    188       print line
    189     print '%s returned %d' % (args[0], popen.returncode)
    190   return popen.returncode
    191 
    192 
    193 def GenerateMsi(target, source, parameters):
    194   """Generates .msi from the installation files prepared by Chromium build."""
    195   parameters['basename'] = os.path.splitext(os.path.basename(source))[0]
    196 
    197   # The script can handle both forms of input a directory with unpacked files or
    198   # a ZIP archive with the same files. In the latter case the archive should be
    199   # unpacked to the intermediate directory.
    200   source_dir = None
    201   if os.path.isdir(source):
    202     # Just use unpacked files from the supplied directory.
    203     source_dir = source
    204   else:
    205     # Unpack .zip
    206     rc = UnpackZip(parameters['intermediate_dir'], source)
    207     if rc != 0:
    208       return rc
    209     source_dir = '%(intermediate_dir)s\\%(basename)s' % parameters
    210 
    211   # Read parameters from 'parameters.json'.
    212   f = open(os.path.join(source_dir, 'parameters.json'))
    213   parameters = Merge(json.load(f), parameters)
    214   f.close()
    215 
    216   if 'source' not in parameters:
    217     print 'The source .wxs is not specified'
    218     return 1
    219 
    220   if 'bind_path' not in parameters:
    221     print 'The binding path is not specified'
    222     return 1
    223 
    224   wxs = os.path.join(source_dir, parameters['source'])
    225 
    226   #  Add the binding path to the light-specific parameters.
    227   bind_path = os.path.join(source_dir, parameters['bind_path'])
    228   parameters = Merge(parameters, {'light': {'switches': ['-b', bind_path]}})
    229 
    230   target_arch = parameters['target_arch']
    231   if target_arch == 'ia32':
    232     arch_param = 'x86'
    233   elif target_arch == 'x64':
    234     arch_param = 'x64'
    235   else:
    236     print 'Invalid target_arch parameter value'
    237     return 1
    238 
    239   # Add the architecture to candle-specific parameters.
    240   parameters = Merge(
    241       parameters, {'candle': {'switches': ['-arch', arch_param]}})
    242 
    243   # Run candle and light to generate the installation.
    244   wixobj = '%(intermediate_dir)s\\%(basename)s.wixobj' % parameters
    245   args = GenerateCommandLine('candle', wxs, wixobj, parameters)
    246   rc = Run(args)
    247   if rc:
    248     return rc
    249 
    250   args = GenerateCommandLine('light', wixobj, target, parameters)
    251   rc = Run(args)
    252   if rc:
    253     return rc
    254 
    255   return 0
    256 
    257 
    258 def main():
    259   usage = 'Usage: zip2msi [options] <input.zip> <output.msi>'
    260   parser = OptionParser(usage=usage)
    261   parser.add_option('--intermediate_dir', dest='intermediate_dir', default='.')
    262   parser.add_option('--wix_path', dest='wix_path', default='.')
    263   parser.add_option('--target_arch', dest='target_arch', default='x86')
    264   options, args = parser.parse_args()
    265   if len(args) != 2:
    266     parser.error('two positional arguments expected')
    267 
    268   return GenerateMsi(args[1], args[0], dict(options.__dict__))
    269 
    270 if __name__ == '__main__':
    271   sys.exit(main())
    272 
    273