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