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