1"""SCons.Tool.packaging.msi
2
3The msi packager.
4"""
5
6#
7# Copyright (c) 2001 - 2014 The SCons Foundation
8#
9# Permission is hereby granted, free of charge, to any person obtaining
10# a copy of this software and associated documentation files (the
11# "Software"), to deal in the Software without restriction, including
12# without limitation the rights to use, copy, modify, merge, publish,
13# distribute, sublicense, and/or sell copies of the Software, and to
14# permit persons to whom the Software is furnished to do so, subject to
15# the following conditions:
16#
17# The above copyright notice and this permission notice shall be included
18# in all copies or substantial portions of the Software.
19#
20# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
21# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
22# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
24# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
25# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
26# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
27
28__revision__ = "src/engine/SCons/Tool/packaging/msi.py  2014/08/24 12:12:31 garyo"
29
30import os
31import SCons
32from SCons.Action import Action
33from SCons.Builder import Builder
34
35from xml.dom.minidom import *
36from xml.sax.saxutils import escape
37
38from SCons.Tool.packaging import stripinstallbuilder
39
40#
41# Utility functions
42#
43def convert_to_id(s, id_set):
44    """ Some parts of .wxs need an Id attribute (for example: The File and
45    Directory directives. The charset is limited to A-Z, a-z, digits,
46    underscores, periods. Each Id must begin with a letter or with a
47    underscore. Google for "CNDL0015" for information about this.
48
49    Requirements:
50     * the string created must only contain chars from the target charset.
51     * the string created must have a minimal editing distance from the
52       original string.
53     * the string created must be unique for the whole .wxs file.
54
55    Observation:
56     * There are 62 chars in the charset.
57
58    Idea:
59     * filter out forbidden characters. Check for a collision with the help
60       of the id_set. Add the number of the number of the collision at the
61       end of the created string. Furthermore care for a correct start of
62       the string.
63    """
64    charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYabcdefghijklmnopqrstuvwxyz0123456789_.'
65    if s[0] in '0123456789.':
66        s += '_'+s
67    id = [c for c in s if c in charset]
68
69    # did we already generate an id for this file?
70    try:
71        return id_set[id][s]
72    except KeyError:
73        # no we did not so initialize with the id
74        if id not in id_set: id_set[id] = { s : id }
75        # there is a collision, generate an id which is unique by appending
76        # the collision number
77        else: id_set[id][s] = id + str(len(id_set[id]))
78
79        return id_set[id][s]
80
81def is_dos_short_file_name(file):
82    """ examine if the given file is in the 8.3 form.
83    """
84    fname, ext = os.path.splitext(file)
85    proper_ext = len(ext) == 0 or (2 <= len(ext) <= 4) # the ext contains the dot
86    proper_fname = file.isupper() and len(fname) <= 8
87
88    return proper_ext and proper_fname
89
90def gen_dos_short_file_name(file, filename_set):
91    """ see http://support.microsoft.com/default.aspx?scid=kb;en-us;Q142982
92
93    These are no complete 8.3 dos short names. The ~ char is missing and
94    replaced with one character from the filename. WiX warns about such
95    filenames, since a collision might occur. Google for "CNDL1014" for
96    more information.
97    """
98    # guard this to not confuse the generation
99    if is_dos_short_file_name(file):
100        return file
101
102    fname, ext = os.path.splitext(file) # ext contains the dot
103
104    # first try if it suffices to convert to upper
105    file = file.upper()
106    if is_dos_short_file_name(file):
107        return file
108
109    # strip forbidden characters.
110    forbidden = '."/[]:;=, '
111    fname = [c for c in fname if c not in forbidden]
112
113    # check if we already generated a filename with the same number:
114    # thisis1.txt, thisis2.txt etc.
115    duplicate, num = not None, 1
116    while duplicate:
117        shortname = "%s%s" % (fname[:8-len(str(num))].upper(),\
118                              str(num))
119        if len(ext) >= 2:
120            shortname = "%s%s" % (shortname, ext[:4].upper())
121
122        duplicate, num = shortname in filename_set, num+1
123
124    assert( is_dos_short_file_name(shortname) ), 'shortname is %s, longname is %s' % (shortname, file)
125    filename_set.append(shortname)
126    return shortname
127
128def create_feature_dict(files):
129    """ X_MSI_FEATURE and doc FileTag's can be used to collect files in a
130        hierarchy. This function collects the files into this hierarchy.
131    """
132    dict = {}
133
134    def add_to_dict( feature, file ):
135        if not SCons.Util.is_List( feature ):
136            feature = [ feature ]
137
138        for f in feature:
139            if f not in dict:
140                dict[ f ] = [ file ]
141            else:
142                dict[ f ].append( file )
143
144    for file in files:
145        if hasattr( file, 'PACKAGING_X_MSI_FEATURE' ):
146            add_to_dict(file.PACKAGING_X_MSI_FEATURE, file)
147        elif hasattr( file, 'PACKAGING_DOC' ):
148            add_to_dict( 'PACKAGING_DOC', file )
149        else:
150            add_to_dict( 'default', file )
151
152    return dict
153
154def generate_guids(root):
155    """ generates globally unique identifiers for parts of the xml which need
156    them.
157
158    Component tags have a special requirement. Their UUID is only allowed to
159    change if the list of their contained resources has changed. This allows
160    for clean removal and proper updates.
161
162    To handle this requirement, the uuid is generated with an md5 hashing the
163    whole subtree of a xml node.
164    """
165    from hashlib import md5
166
167    # specify which tags need a guid and in which attribute this should be stored.
168    needs_id = { 'Product'   : 'Id',
169                 'Package'   : 'Id',
170                 'Component' : 'Guid',
171               }
172
173    # find all XMl nodes matching the key, retrieve their attribute, hash their
174    # subtree, convert hash to string and add as a attribute to the xml node.
175    for (key,value) in needs_id.items():
176        node_list = root.getElementsByTagName(key)
177        attribute = value
178        for node in node_list:
179            hash = md5(node.toxml()).hexdigest()
180            hash_str = '%s-%s-%s-%s-%s' % ( hash[:8], hash[8:12], hash[12:16], hash[16:20], hash[20:] )
181            node.attributes[attribute] = hash_str
182
183
184
185def string_wxsfile(target, source, env):
186    return "building WiX file %s"%( target[0].path )
187
188def build_wxsfile(target, source, env):
189    """ compiles a .wxs file from the keywords given in env['msi_spec'] and
190        by analyzing the tree of source nodes and their tags.
191    """
192    file = open(target[0].abspath, 'w')
193
194    try:
195        # Create a document with the Wix root tag
196        doc  = Document()
197        root = doc.createElement( 'Wix' )
198        root.attributes['xmlns']='http://schemas.microsoft.com/wix/2003/01/wi'
199        doc.appendChild( root )
200
201        filename_set = [] # this is to circumvent duplicates in the shortnames
202        id_set       = {} # this is to circumvent duplicates in the ids
203
204        # Create the content
205        build_wxsfile_header_section(root, env)
206        build_wxsfile_file_section(root, source, env['NAME'], env['VERSION'], env['VENDOR'], filename_set, id_set)
207        generate_guids(root)
208        build_wxsfile_features_section(root, source, env['NAME'], env['VERSION'], env['SUMMARY'], id_set)
209        build_wxsfile_default_gui(root)
210        build_license_file(target[0].get_dir(), env)
211
212        # write the xml to a file
213        file.write( doc.toprettyxml() )
214
215        # call a user specified function
216        if 'CHANGE_SPECFILE' in env:
217            env['CHANGE_SPECFILE'](target, source)
218
219    except KeyError, e:
220        raise SCons.Errors.UserError( '"%s" package field for MSI is missing.' % e.args[0] )
221
222#
223# setup function
224#
225def create_default_directory_layout(root, NAME, VERSION, VENDOR, filename_set):
226    """ Create the wix default target directory layout and return the innermost
227    directory.
228
229    We assume that the XML tree delivered in the root argument already contains
230    the Product tag.
231
232    Everything is put under the PFiles directory property defined by WiX.
233    After that a directory  with the 'VENDOR' tag is placed and then a
234    directory with the name of the project and its VERSION. This leads to the
235    following TARGET Directory Layout:
236    C:\<PFiles>\<Vendor>\<Projectname-Version>\
237    Example: C:\Programme\Company\Product-1.2\
238    """
239    doc = Document()
240    d1  = doc.createElement( 'Directory' )
241    d1.attributes['Id']   = 'TARGETDIR'
242    d1.attributes['Name'] = 'SourceDir'
243
244    d2  = doc.createElement( 'Directory' )
245    d2.attributes['Id']   = 'ProgramFilesFolder'
246    d2.attributes['Name'] = 'PFiles'
247
248    d3 = doc.createElement( 'Directory' )
249    d3.attributes['Id']       = 'VENDOR_folder'
250    d3.attributes['Name']     = escape( gen_dos_short_file_name( VENDOR, filename_set ) )
251    d3.attributes['LongName'] = escape( VENDOR )
252
253    d4 = doc.createElement( 'Directory' )
254    project_folder            = "%s-%s" % ( NAME, VERSION )
255    d4.attributes['Id']       = 'MY_DEFAULT_FOLDER'
256    d4.attributes['Name']     = escape( gen_dos_short_file_name( project_folder, filename_set ) )
257    d4.attributes['LongName'] = escape( project_folder )
258
259    d1.childNodes.append( d2 )
260    d2.childNodes.append( d3 )
261    d3.childNodes.append( d4 )
262
263    root.getElementsByTagName('Product')[0].childNodes.append( d1 )
264
265    return d4
266
267#
268# mandatory and optional file tags
269#
270def build_wxsfile_file_section(root, files, NAME, VERSION, VENDOR, filename_set, id_set):
271    """ builds the Component sections of the wxs file with their included files.
272
273    Files need to be specified in 8.3 format and in the long name format, long
274    filenames will be converted automatically.
275
276    Features are specficied with the 'X_MSI_FEATURE' or 'DOC' FileTag.
277    """
278    root       = create_default_directory_layout( root, NAME, VERSION, VENDOR, filename_set )
279    components = create_feature_dict( files )
280    factory    = Document()
281
282    def get_directory( node, dir ):
283        """ returns the node under the given node representing the directory.
284
285        Returns the component node if dir is None or empty.
286        """
287        if dir == '' or not dir:
288            return node
289
290        Directory = node
291        dir_parts = dir.split(os.path.sep)
292
293        # to make sure that our directory ids are unique, the parent folders are
294        # consecutively added to upper_dir
295        upper_dir = ''
296
297        # walk down the xml tree finding parts of the directory
298        dir_parts = [d for d in dir_parts if d != '']
299        for d in dir_parts[:]:
300            already_created = [c for c in Directory.childNodes
301                               if c.nodeName == 'Directory'
302                               and c.attributes['LongName'].value == escape(d)]
303
304            if already_created != []:
305                Directory = already_created[0]
306                dir_parts.remove(d)
307                upper_dir += d
308            else:
309                break
310
311        for d in dir_parts:
312            nDirectory = factory.createElement( 'Directory' )
313            nDirectory.attributes['LongName'] = escape( d )
314            nDirectory.attributes['Name']     = escape( gen_dos_short_file_name( d, filename_set ) )
315            upper_dir += d
316            nDirectory.attributes['Id']       = convert_to_id( upper_dir, id_set )
317
318            Directory.childNodes.append( nDirectory )
319            Directory = nDirectory
320
321        return Directory
322
323    for file in files:
324        drive, path = os.path.splitdrive( file.PACKAGING_INSTALL_LOCATION )
325        filename = os.path.basename( path )
326        dirname  = os.path.dirname( path )
327
328        h = {
329            # tagname                   : default value
330            'PACKAGING_X_MSI_VITAL'     : 'yes',
331            'PACKAGING_X_MSI_FILEID'    : convert_to_id(filename, id_set),
332            'PACKAGING_X_MSI_LONGNAME'  : filename,
333            'PACKAGING_X_MSI_SHORTNAME' : gen_dos_short_file_name(filename, filename_set),
334            'PACKAGING_X_MSI_SOURCE'    : file.get_path(),
335            }
336
337        # fill in the default tags given above.
338        for k,v in [ (k, v) for (k,v) in h.items() if not hasattr(file, k) ]:
339            setattr( file, k, v )
340
341        File = factory.createElement( 'File' )
342        File.attributes['LongName'] = escape( file.PACKAGING_X_MSI_LONGNAME )
343        File.attributes['Name']     = escape( file.PACKAGING_X_MSI_SHORTNAME )
344        File.attributes['Source']   = escape( file.PACKAGING_X_MSI_SOURCE )
345        File.attributes['Id']       = escape( file.PACKAGING_X_MSI_FILEID )
346        File.attributes['Vital']    = escape( file.PACKAGING_X_MSI_VITAL )
347
348        # create the <Component> Tag under which this file should appear
349        Component = factory.createElement('Component')
350        Component.attributes['DiskId'] = '1'
351        Component.attributes['Id']     = convert_to_id( filename, id_set )
352
353        # hang the component node under the root node and the file node
354        # under the component node.
355        Directory = get_directory( root, dirname )
356        Directory.childNodes.append( Component )
357        Component.childNodes.append( File )
358
359#
360# additional functions
361#
362def build_wxsfile_features_section(root, files, NAME, VERSION, SUMMARY, id_set):
363    """ This function creates the <features> tag based on the supplied xml tree.
364
365    This is achieved by finding all <component>s and adding them to a default target.
366
367    It should be called after the tree has been built completly.  We assume
368    that a MY_DEFAULT_FOLDER Property is defined in the wxs file tree.
369
370    Furthermore a top-level with the name and VERSION of the software will be created.
371
372    An PACKAGING_X_MSI_FEATURE can either be a string, where the feature
373    DESCRIPTION will be the same as its title or a Tuple, where the first
374    part will be its title and the second its DESCRIPTION.
375    """
376    factory = Document()
377    Feature = factory.createElement('Feature')
378    Feature.attributes['Id']                    = 'complete'
379    Feature.attributes['ConfigurableDirectory'] = 'MY_DEFAULT_FOLDER'
380    Feature.attributes['Level']                 = '1'
381    Feature.attributes['Title']                 = escape( '%s %s' % (NAME, VERSION) )
382    Feature.attributes['Description']           = escape( SUMMARY )
383    Feature.attributes['Display']               = 'expand'
384
385    for (feature, files) in create_feature_dict(files).items():
386        SubFeature   = factory.createElement('Feature')
387        SubFeature.attributes['Level'] = '1'
388
389        if SCons.Util.is_Tuple(feature):
390            SubFeature.attributes['Id']    = convert_to_id( feature[0], id_set )
391            SubFeature.attributes['Title'] = escape(feature[0])
392            SubFeature.attributes['Description'] = escape(feature[1])
393        else:
394            SubFeature.attributes['Id'] = convert_to_id( feature, id_set )
395            if feature=='default':
396                SubFeature.attributes['Description'] = 'Main Part'
397                SubFeature.attributes['Title'] = 'Main Part'
398            elif feature=='PACKAGING_DOC':
399                SubFeature.attributes['Description'] = 'Documentation'
400                SubFeature.attributes['Title'] = 'Documentation'
401            else:
402                SubFeature.attributes['Description'] = escape(feature)
403                SubFeature.attributes['Title'] = escape(feature)
404
405        # build the componentrefs. As one of the design decision is that every
406        # file is also a component we walk the list of files and create a
407        # reference.
408        for f in files:
409            ComponentRef = factory.createElement('ComponentRef')
410            ComponentRef.attributes['Id'] = convert_to_id( os.path.basename(f.get_path()), id_set )
411            SubFeature.childNodes.append(ComponentRef)
412
413        Feature.childNodes.append(SubFeature)
414
415    root.getElementsByTagName('Product')[0].childNodes.append(Feature)
416
417def build_wxsfile_default_gui(root):
418    """ this function adds a default GUI to the wxs file
419    """
420    factory = Document()
421    Product = root.getElementsByTagName('Product')[0]
422
423    UIRef   = factory.createElement('UIRef')
424    UIRef.attributes['Id'] = 'WixUI_Mondo'
425    Product.childNodes.append(UIRef)
426
427    UIRef   = factory.createElement('UIRef')
428    UIRef.attributes['Id'] = 'WixUI_ErrorProgressText'
429    Product.childNodes.append(UIRef)
430
431def build_license_file(directory, spec):
432    """ creates a License.rtf file with the content of "X_MSI_LICENSE_TEXT"
433    in the given directory
434    """
435    name, text = '', ''
436
437    try:
438        name = spec['LICENSE']
439        text = spec['X_MSI_LICENSE_TEXT']
440    except KeyError:
441        pass # ignore this as X_MSI_LICENSE_TEXT is optional
442
443    if name!='' or text!='':
444        file = open( os.path.join(directory.get_path(), 'License.rtf'), 'w' )
445        file.write('{\\rtf')
446        if text!='':
447             file.write(text.replace('\n', '\\par '))
448        else:
449             file.write(name+'\\par\\par')
450        file.write('}')
451        file.close()
452
453#
454# mandatory and optional package tags
455#
456def build_wxsfile_header_section(root, spec):
457    """ Adds the xml file node which define the package meta-data.
458    """
459    # Create the needed DOM nodes and add them at the correct position in the tree.
460    factory = Document()
461    Product = factory.createElement( 'Product' )
462    Package = factory.createElement( 'Package' )
463
464    root.childNodes.append( Product )
465    Product.childNodes.append( Package )
466
467    # set "mandatory" default values
468    if 'X_MSI_LANGUAGE' not in spec:
469        spec['X_MSI_LANGUAGE'] = '1033' # select english
470
471    # mandatory sections, will throw a KeyError if the tag is not available
472    Product.attributes['Name']         = escape( spec['NAME'] )
473    Product.attributes['Version']      = escape( spec['VERSION'] )
474    Product.attributes['Manufacturer'] = escape( spec['VENDOR'] )
475    Product.attributes['Language']     = escape( spec['X_MSI_LANGUAGE'] )
476    Package.attributes['Description']  = escape( spec['SUMMARY'] )
477
478    # now the optional tags, for which we avoid the KeyErrror exception
479    if 'DESCRIPTION' in spec:
480        Package.attributes['Comments'] = escape( spec['DESCRIPTION'] )
481
482    if 'X_MSI_UPGRADE_CODE' in spec:
483        Package.attributes['X_MSI_UPGRADE_CODE'] = escape( spec['X_MSI_UPGRADE_CODE'] )
484
485    # We hardcode the media tag as our current model cannot handle it.
486    Media = factory.createElement('Media')
487    Media.attributes['Id']       = '1'
488    Media.attributes['Cabinet']  = 'default.cab'
489    Media.attributes['EmbedCab'] = 'yes'
490    root.getElementsByTagName('Product')[0].childNodes.append(Media)
491
492# this builder is the entry-point for .wxs file compiler.
493wxs_builder = Builder(
494    action         = Action( build_wxsfile, string_wxsfile ),
495    ensure_suffix  = '.wxs' )
496
497def package(env, target, source, PACKAGEROOT, NAME, VERSION,
498            DESCRIPTION, SUMMARY, VENDOR, X_MSI_LANGUAGE, **kw):
499    # make sure that the Wix Builder is in the environment
500    SCons.Tool.Tool('wix').generate(env)
501
502    # get put the keywords for the specfile compiler. These are the arguments
503    # given to the package function and all optional ones stored in kw, minus
504    # the the source, target and env one.
505    loc = locals()
506    del loc['kw']
507    kw.update(loc)
508    del kw['source'], kw['target'], kw['env']
509
510    # strip the install builder from the source files
511    target, source = stripinstallbuilder(target, source, env)
512
513    # put the arguments into the env and call the specfile builder.
514    env['msi_spec'] = kw
515    specfile = wxs_builder(* [env, target, source], **kw)
516
517    # now call the WiX Tool with the built specfile added as a source.
518    msifile  = env.WiX(target, specfile)
519
520    # return the target and source tuple.
521    return (msifile, source+[specfile])
522
523# Local Variables:
524# tab-width:4
525# indent-tabs-mode:nil
526# End:
527# vim: set expandtab tabstop=4 shiftwidth=4:
528