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 8The structure of the input archive or directory should look like this: 9 10 +- archive.zip 11 +- archive 12 +- parameters.json 13 14The name of the archive and the top level directory in the archive must match. 15When an unpacked directory is used as the input "archive.zip/archive" should 16be passed via the command line. 17 18'parameters.json' specifies the parameters to be passed to candle/light and 19must 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 37This script is used for both building Chromoting Host installation during 38Chromuim build and for signing Chromoting Host installation later. There are two 39copies 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 44The copies of the script can be out of sync so make sure that a newer version is 45compatible with the older ones when updating the script. 46""" 47 48import copy 49import json 50from optparse import OptionParser 51import os 52import re 53import subprocess 54import sys 55import zipfile 56 57 58def 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 79def 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 117quote_matcher_regex = re.compile(r'\s|"') 118quote_replacer_regex = re.compile(r'(\\*)"') 119 120 121def 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 151def 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 179def 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 193def 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 # Run candle and light to generate the installation. 231 wixobj = '%(intermediate_dir)s\\%(basename)s.wixobj' % parameters 232 args = GenerateCommandLine('candle', wxs, wixobj, parameters) 233 rc = Run(args) 234 if rc: 235 return rc 236 237 args = GenerateCommandLine('light', wixobj, target, parameters) 238 rc = Run(args) 239 if rc: 240 return rc 241 242 return 0 243 244 245def main(): 246 usage = 'Usage: zip2msi [options] <input.zip> <output.msi>' 247 parser = OptionParser(usage=usage) 248 parser.add_option('--intermediate_dir', dest='intermediate_dir', default='.') 249 parser.add_option('--wix_path', dest='wix_path', default='.') 250 options, args = parser.parse_args() 251 if len(args) != 2: 252 parser.error('two positional arguments expected') 253 254 return GenerateMsi(args[1], args[0], dict(options.__dict__)) 255 256if __name__ == '__main__': 257 sys.exit(main()) 258 259