1# Microsoft Installer Library
2# (C) 2003 Martin v. Loewis
3
4import win32com.client.gencache
5import win32com.client
6import pythoncom, pywintypes
7from win32com.client import constants
8import re, string, os, sets, glob, subprocess, sys, _winreg, struct
9
10try:
11    basestring
12except NameError:
13    basestring = (str, unicode)
14
15# Partially taken from Wine
16datasizemask=      0x00ff
17type_valid=        0x0100
18type_localizable=  0x0200
19
20typemask=          0x0c00
21type_long=         0x0000
22type_short=        0x0400
23type_string=       0x0c00
24type_binary=       0x0800
25
26type_nullable=     0x1000
27type_key=          0x2000
28# XXX temporary, localizable?
29knownbits = datasizemask | type_valid | type_localizable | \
30            typemask | type_nullable | type_key
31
32# Summary Info Property IDs
33PID_CODEPAGE=1
34PID_TITLE=2
35PID_SUBJECT=3
36PID_AUTHOR=4
37PID_KEYWORDS=5
38PID_COMMENTS=6
39PID_TEMPLATE=7
40PID_LASTAUTHOR=8
41PID_REVNUMBER=9
42PID_LASTPRINTED=11
43PID_CREATE_DTM=12
44PID_LASTSAVE_DTM=13
45PID_PAGECOUNT=14
46PID_WORDCOUNT=15
47PID_CHARCOUNT=16
48PID_APPNAME=18
49PID_SECURITY=19
50
51def reset():
52    global _directories
53    _directories = sets.Set()
54
55def EnsureMSI():
56    win32com.client.gencache.EnsureModule('{000C1092-0000-0000-C000-000000000046}', 1033, 1, 0)
57
58def EnsureMSM():
59    try:
60        win32com.client.gencache.EnsureModule('{0ADDA82F-2C26-11D2-AD65-00A0C9AF11A6}', 0, 1, 0)
61    except pywintypes.com_error:
62        win32com.client.gencache.EnsureModule('{0ADDA82F-2C26-11D2-AD65-00A0C9AF11A6}', 0, 2, 0)
63
64_Installer=None
65def MakeInstaller():
66    global _Installer
67    if _Installer is None:
68        EnsureMSI()
69        _Installer = win32com.client.Dispatch('WindowsInstaller.Installer',
70                     resultCLSID='{000C1090-0000-0000-C000-000000000046}')
71    return _Installer
72
73_Merge=None
74def MakeMerge2():
75    global _Merge
76    if _Merge is None:
77        EnsureMSM()
78        _Merge = win32com.client.Dispatch("Msm.Merge2.1")
79    return _Merge
80
81class Table:
82    def __init__(self, name):
83        self.name = name
84        self.fields = []
85
86    def add_field(self, index, name, type):
87        self.fields.append((index,name,type))
88
89    def sql(self):
90        fields = []
91        keys = []
92        self.fields.sort()
93        fields = [None]*len(self.fields)
94        for index, name, type in self.fields:
95            index -= 1
96            unk = type & ~knownbits
97            if unk:
98                print "%s.%s unknown bits %x" % (self.name, name, unk)
99            size = type & datasizemask
100            dtype = type & typemask
101            if dtype == type_string:
102                if size:
103                    tname="CHAR(%d)" % size
104                else:
105                    tname="CHAR"
106            elif dtype == type_short:
107                assert size==2
108                tname = "SHORT"
109            elif dtype == type_long:
110                assert size==4
111                tname="LONG"
112            elif dtype == type_binary:
113                assert size==0
114                tname="OBJECT"
115            else:
116                tname="unknown"
117                print "%s.%sunknown integer type %d" % (self.name, name, size)
118            if type & type_nullable:
119                flags = ""
120            else:
121                flags = " NOT NULL"
122            if type & type_localizable:
123                flags += " LOCALIZABLE"
124            fields[index] = "`%s` %s%s" % (name, tname, flags)
125            if type & type_key:
126                keys.append("`%s`" % name)
127        fields = ", ".join(fields)
128        keys = ", ".join(keys)
129        return "CREATE TABLE %s (%s PRIMARY KEY %s)" % (self.name, fields, keys)
130
131    def create(self, db):
132        v = db.OpenView(self.sql())
133        v.Execute(None)
134        v.Close()
135
136class Binary:
137    def __init__(self, fname):
138        self.name = fname
139    def __repr__(self):
140        return 'msilib.Binary(os.path.join(dirname,"%s"))' % self.name
141
142def gen_schema(destpath, schemapath):
143    d = MakeInstaller()
144    schema = d.OpenDatabase(schemapath,
145            win32com.client.constants.msiOpenDatabaseModeReadOnly)
146
147    # XXX ORBER BY
148    v=schema.OpenView("SELECT * FROM _Columns")
149    curtable=None
150    tables = []
151    v.Execute(None)
152    f = open(destpath, "wt")
153    f.write("from msilib import Table\n")
154    while 1:
155        r=v.Fetch()
156        if not r:break
157        name=r.StringData(1)
158        if curtable != name:
159            f.write("\n%s = Table('%s')\n" % (name,name))
160            curtable = name
161            tables.append(name)
162        f.write("%s.add_field(%d,'%s',%d)\n" %
163                (name, r.IntegerData(2), r.StringData(3), r.IntegerData(4)))
164    v.Close()
165
166    f.write("\ntables=[%s]\n\n" % (", ".join(tables)))
167
168    # Fill the _Validation table
169    f.write("_Validation_records = [\n")
170    v = schema.OpenView("SELECT * FROM _Validation")
171    v.Execute(None)
172    while 1:
173        r = v.Fetch()
174        if not r:break
175        # Table, Column, Nullable
176        f.write("(%s,%s,%s," %
177                (`r.StringData(1)`, `r.StringData(2)`, `r.StringData(3)`))
178        def put_int(i):
179            if r.IsNull(i):f.write("None, ")
180            else:f.write("%d," % r.IntegerData(i))
181        def put_str(i):
182            if r.IsNull(i):f.write("None, ")
183            else:f.write("%s," % `r.StringData(i)`)
184        put_int(4) # MinValue
185        put_int(5) # MaxValue
186        put_str(6) # KeyTable
187        put_int(7) # KeyColumn
188        put_str(8) # Category
189        put_str(9) # Set
190        put_str(10)# Description
191        f.write("),\n")
192    f.write("]\n\n")
193
194    f.close()
195
196def gen_sequence(destpath, msipath):
197    dir = os.path.dirname(destpath)
198    d = MakeInstaller()
199    seqmsi = d.OpenDatabase(msipath,
200            win32com.client.constants.msiOpenDatabaseModeReadOnly)
201
202    v = seqmsi.OpenView("SELECT * FROM _Tables");
203    v.Execute(None)
204    f = open(destpath, "w")
205    print >>f, "import msilib,os;dirname=os.path.dirname(__file__)"
206    tables = []
207    while 1:
208        r = v.Fetch()
209        if not r:break
210        table = r.StringData(1)
211        tables.append(table)
212        f.write("%s = [\n" % table)
213        v1 = seqmsi.OpenView("SELECT * FROM `%s`" % table)
214        v1.Execute(None)
215        info = v1.ColumnInfo(constants.msiColumnInfoTypes)
216        while 1:
217            r = v1.Fetch()
218            if not r:break
219            rec = []
220            for i in range(1,r.FieldCount+1):
221                if r.IsNull(i):
222                    rec.append(None)
223                elif info.StringData(i)[0] in "iI":
224                    rec.append(r.IntegerData(i))
225                elif info.StringData(i)[0] in "slSL":
226                    rec.append(r.StringData(i))
227                elif info.StringData(i)[0]=="v":
228                    size = r.DataSize(i)
229                    bytes = r.ReadStream(i, size, constants.msiReadStreamBytes)
230                    bytes = bytes.encode("latin-1") # binary data represented "as-is"
231                    if table == "Binary":
232                        fname = rec[0]+".bin"
233                        open(os.path.join(dir,fname),"wb").write(bytes)
234                        rec.append(Binary(fname))
235                    else:
236                        rec.append(bytes)
237                else:
238                    raise "Unsupported column type", info.StringData(i)
239            f.write(repr(tuple(rec))+",\n")
240        v1.Close()
241        f.write("]\n\n")
242    v.Close()
243    f.write("tables=%s\n" % repr(map(str,tables)))
244    f.close()
245
246class _Unspecified:pass
247def change_sequence(seq, action, seqno=_Unspecified, cond = _Unspecified):
248    "Change the sequence number of an action in a sequence list"
249    for i in range(len(seq)):
250        if seq[i][0] == action:
251            if cond is _Unspecified:
252                cond = seq[i][1]
253            if seqno is _Unspecified:
254                seqno = seq[i][2]
255            seq[i] = (action, cond, seqno)
256            return
257    raise ValueError, "Action not found in sequence"
258
259def add_data(db, table, values):
260    d = MakeInstaller()
261    v = db.OpenView("SELECT * FROM `%s`" % table)
262    count = v.ColumnInfo(0).FieldCount
263    r = d.CreateRecord(count)
264    for value in values:
265        assert len(value) == count, value
266        for i in range(count):
267            field = value[i]
268            if isinstance(field, (int, long)):
269                r.SetIntegerData(i+1,field)
270            elif isinstance(field, basestring):
271                r.SetStringData(i+1,field)
272            elif field is None:
273                pass
274            elif isinstance(field, Binary):
275                r.SetStream(i+1, field.name)
276            else:
277                raise TypeError, "Unsupported type %s" % field.__class__.__name__
278        v.Modify(win32com.client.constants.msiViewModifyInsert, r)
279        r.ClearData()
280    v.Close()
281
282def add_stream(db, name, path):
283    d = MakeInstaller()
284    v = db.OpenView("INSERT INTO _Streams (Name, Data) VALUES ('%s', ?)" % name)
285    r = d.CreateRecord(1)
286    r.SetStream(1, path)
287    v.Execute(r)
288    v.Close()
289
290def init_database(name, schema,
291                  ProductName, ProductCode, ProductVersion,
292                  Manufacturer,
293                  request_uac = False):
294    try:
295        os.unlink(name)
296    except OSError:
297        pass
298    ProductCode = ProductCode.upper()
299    d = MakeInstaller()
300    # Create the database
301    db = d.OpenDatabase(name,
302         win32com.client.constants.msiOpenDatabaseModeCreate)
303    # Create the tables
304    for t in schema.tables:
305        t.create(db)
306    # Fill the validation table
307    add_data(db, "_Validation", schema._Validation_records)
308    # Initialize the summary information, allowing at most 20 properties
309    si = db.GetSummaryInformation(20)
310    si.SetProperty(PID_TITLE, "Installation Database")
311    si.SetProperty(PID_SUBJECT, ProductName)
312    si.SetProperty(PID_AUTHOR, Manufacturer)
313    si.SetProperty(PID_TEMPLATE, msi_type)
314    si.SetProperty(PID_REVNUMBER, gen_uuid())
315    if request_uac:
316        wc = 2 # long file names, compressed, original media
317    else:
318        wc = 2 | 8 # +never invoke UAC
319    si.SetProperty(PID_WORDCOUNT, wc)
320    si.SetProperty(PID_PAGECOUNT, 200)
321    si.SetProperty(PID_APPNAME, "Python MSI Library")
322    # XXX more properties
323    si.Persist()
324    add_data(db, "Property", [
325        ("ProductName", ProductName),
326        ("ProductCode", ProductCode),
327        ("ProductVersion", ProductVersion),
328        ("Manufacturer", Manufacturer),
329        ("ProductLanguage", "1033")])
330    db.Commit()
331    return db
332
333def add_tables(db, module):
334    for table in module.tables:
335        add_data(db, table, getattr(module, table))
336
337def make_id(str):
338    #str = str.replace(".", "_") # colons are allowed
339    str = str.replace(" ", "_")
340    str = str.replace("-", "_")
341    str = str.replace("+", "_")
342    if str[0] in string.digits:
343        str = "_"+str
344    assert re.match("^[A-Za-z_][A-Za-z0-9_.]*$", str), "FILE"+str
345    return str
346
347def gen_uuid():
348    return str(pythoncom.CreateGuid())
349
350class CAB:
351    def __init__(self, name):
352        self.name = name
353        self.file = open(name+".txt", "wt")
354        self.filenames = sets.Set()
355        self.index = 0
356
357    def gen_id(self, dir, file):
358        logical = _logical = make_id(file)
359        pos = 1
360        while logical in self.filenames:
361            logical = "%s.%d" % (_logical, pos)
362            pos += 1
363        self.filenames.add(logical)
364        return logical
365
366    def append(self, full, file, logical = None):
367        if os.path.isdir(full):
368            return
369        if not logical:
370            logical = self.gen_id(dir, file)
371        self.index += 1
372        if full.find(" ")!=-1:
373            print >>self.file, '"%s" %s' % (full, logical)
374        else:
375            print >>self.file, '%s %s' % (full, logical)
376        return self.index, logical
377
378    def commit(self, db):
379        self.file.close()
380        try:
381            os.unlink(self.name+".cab")
382        except OSError:
383            pass
384        for k, v in [(r"Software\Microsoft\VisualStudio\7.1\Setup\VS", "VS7CommonBinDir"),
385                     (r"Software\Microsoft\VisualStudio\8.0\Setup\VS", "VS7CommonBinDir"),
386                     (r"Software\Microsoft\VisualStudio\9.0\Setup\VS", "VS7CommonBinDir"),
387                     (r"Software\Microsoft\Win32SDK\Directories", "Install Dir"),
388                    ]:
389            try:
390                key = _winreg.OpenKey(_winreg.HKEY_LOCAL_MACHINE, k)
391                dir = _winreg.QueryValueEx(key, v)[0]
392                _winreg.CloseKey(key)
393            except (WindowsError, IndexError):
394                continue
395            cabarc = os.path.join(dir, r"Bin", "cabarc.exe")
396            if not os.path.exists(cabarc):
397                continue
398            break
399        else:
400            print "WARNING: cabarc.exe not found in registry"
401            cabarc = "cabarc.exe"
402        cmd = r'"%s" -m lzx:21 n %s.cab @%s.txt' % (cabarc, self.name, self.name)
403        p = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE,
404                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
405        for line in p.stdout:
406            if line.startswith("  -- adding "):
407                sys.stdout.write(".")
408            else:
409                sys.stdout.write(line)
410            sys.stdout.flush()
411        if not os.path.exists(self.name+".cab"):
412            raise IOError, "cabarc failed"
413        add_data(db, "Media",
414                [(1, self.index, None, "#"+self.name, None, None)])
415        add_stream(db, self.name, self.name+".cab")
416        os.unlink(self.name+".txt")
417        os.unlink(self.name+".cab")
418        db.Commit()
419
420_directories = sets.Set()
421class Directory:
422    def __init__(self, db, cab, basedir, physical, _logical, default, componentflags=None):
423        """Create a new directory in the Directory table. There is a current component
424        at each point in time for the directory, which is either explicitly created
425        through start_component, or implicitly when files are added for the first
426        time. Files are added into the current component, and into the cab file.
427        To create a directory, a base directory object needs to be specified (can be
428        None), the path to the physical directory, and a logical directory name.
429        Default specifies the DefaultDir slot in the directory table. componentflags
430        specifies the default flags that new components get."""
431        index = 1
432        _logical = make_id(_logical)
433        logical = _logical
434        while logical in _directories:
435            logical = "%s%d" % (_logical, index)
436            index += 1
437        _directories.add(logical)
438        self.db = db
439        self.cab = cab
440        self.basedir = basedir
441        self.physical = physical
442        self.logical = logical
443        self.component = None
444        self.short_names = sets.Set()
445        self.ids = sets.Set()
446        self.keyfiles = {}
447        self.componentflags = componentflags
448        if basedir:
449            self.absolute = os.path.join(basedir.absolute, physical)
450            blogical = basedir.logical
451        else:
452            self.absolute = physical
453            blogical = None
454        add_data(db, "Directory", [(logical, blogical, default)])
455
456    def start_component(self, component = None, feature = None, flags = None, keyfile = None, uuid=None):
457        """Add an entry to the Component table, and make this component the current for this
458        directory. If no component name is given, the directory name is used. If no feature
459        is given, the current feature is used. If no flags are given, the directory's default
460        flags are used. If no keyfile is given, the KeyPath is left null in the Component
461        table."""
462        if flags is None:
463            flags = self.componentflags
464        if uuid is None:
465            uuid = gen_uuid()
466        else:
467            uuid = uuid.upper()
468        if component is None:
469            component = self.logical
470        self.component = component
471        if Win64:
472            flags |= 256
473        if keyfile:
474            keyid = self.cab.gen_id(self.absolute, keyfile)
475            self.keyfiles[keyfile] = keyid
476        else:
477            keyid = None
478        add_data(self.db, "Component",
479                        [(component, uuid, self.logical, flags, None, keyid)])
480        if feature is None:
481            feature = current_feature
482        add_data(self.db, "FeatureComponents",
483                        [(feature.id, component)])
484
485    def make_short(self, file):
486        file = re.sub(r'[\?|><:/*"+,;=\[\]]', '_', file) # restrictions on short names
487        prefix, _, suffix = file.upper().rpartition(".")
488        if len(prefix) <= 8 and (not suffix or len(suffix)<=3):
489            if suffix:
490                file = prefix+"."+suffix
491            else:
492                file = prefix
493            assert file not in self.short_names
494        else:
495            prefix = prefix[:6]
496            if suffix:
497                suffix = suffix[:3]
498            pos = 1
499            while 1:
500                if suffix:
501                    file = "%s~%d.%s" % (prefix, pos, suffix)
502                else:
503                    file = "%s~%d" % (prefix, pos)
504                if file not in self.short_names: break
505                pos += 1
506                assert pos < 10000
507                if pos in (10, 100, 1000):
508                    prefix = prefix[:-1]
509        self.short_names.add(file)
510        return file
511
512    def add_file(self, file, src=None, version=None, language=None):
513        """Add a file to the current component of the directory, starting a new one
514        if there is no current component. By default, the file name in the source
515        and the file table will be identical. If the src file is specified, it is
516        interpreted relative to the current directory. Optionally, a version and a
517        language can be specified for the entry in the File table."""
518        if not self.component:
519            self.start_component(self.logical, current_feature)
520        if not src:
521            # Allow relative paths for file if src is not specified
522            src = file
523            file = os.path.basename(file)
524        absolute = os.path.join(self.absolute, src)
525        assert not re.search(r'[\?|><:/*]"', file) # restrictions on long names
526        if self.keyfiles.has_key(file):
527            logical = self.keyfiles[file]
528        else:
529            logical = None
530        sequence, logical = self.cab.append(absolute, file, logical)
531        assert logical not in self.ids
532        self.ids.add(logical)
533        short = self.make_short(file)
534        full = "%s|%s" % (short, file)
535        filesize = os.stat(absolute).st_size
536        # constants.msidbFileAttributesVital
537        # Compressed omitted, since it is the database default
538        # could add r/o, system, hidden
539        attributes = 512
540        add_data(self.db, "File",
541                        [(logical, self.component, full, filesize, version,
542                         language, attributes, sequence)])
543        if not version:
544            # Add hash if the file is not versioned
545            filehash = MakeInstaller().FileHash(absolute, 0)
546            add_data(self.db, "MsiFileHash",
547                     [(logical, 0, filehash.IntegerData(1),
548                       filehash.IntegerData(2), filehash.IntegerData(3),
549                       filehash.IntegerData(4))])
550        # Automatically remove .pyc/.pyo files on uninstall (2)
551        # XXX: adding so many RemoveFile entries makes installer unbelievably
552        # slow. So instead, we have to use wildcard remove entries
553        # if file.endswith(".py"):
554        #     add_data(self.db, "RemoveFile",
555        #              [(logical+"c", self.component, "%sC|%sc" % (short, file),
556        #                self.logical, 2),
557        #               (logical+"o", self.component, "%sO|%so" % (short, file),
558        #                self.logical, 2)])
559
560    def glob(self, pattern, exclude = None):
561        """Add a list of files to the current component as specified in the
562        glob pattern. Individual files can be excluded in the exclude list."""
563        files = glob.glob1(self.absolute, pattern)
564        for f in files:
565            if exclude and f in exclude: continue
566            self.add_file(f)
567        return files
568
569    def remove_pyc(self):
570        "Remove .pyc/.pyo files on uninstall"
571        add_data(self.db, "RemoveFile",
572                 [(self.component+"c", self.component, "*.pyc", self.logical, 2),
573                  (self.component+"o", self.component, "*.pyo", self.logical, 2)])
574
575    def removefile(self, key, pattern):
576        "Add a RemoveFile entry"
577        add_data(self.db, "RemoveFile", [(self.component+key, self.component, pattern, self.logical, 2)])
578
579
580class Feature:
581    def __init__(self, db, id, title, desc, display, level = 1,
582                 parent=None, directory = None, attributes=0):
583        self.id = id
584        if parent:
585            parent = parent.id
586        add_data(db, "Feature",
587                        [(id, parent, title, desc, display,
588                          level, directory, attributes)])
589    def set_current(self):
590        global current_feature
591        current_feature = self
592
593class Control:
594    def __init__(self, dlg, name):
595        self.dlg = dlg
596        self.name = name
597
598    def event(self, ev, arg, cond = "1", order = None):
599        add_data(self.dlg.db, "ControlEvent",
600                 [(self.dlg.name, self.name, ev, arg, cond, order)])
601
602    def mapping(self, ev, attr):
603        add_data(self.dlg.db, "EventMapping",
604                 [(self.dlg.name, self.name, ev, attr)])
605
606    def condition(self, action, condition):
607        add_data(self.dlg.db, "ControlCondition",
608                 [(self.dlg.name, self.name, action, condition)])
609
610class RadioButtonGroup(Control):
611    def __init__(self, dlg, name, property):
612        self.dlg = dlg
613        self.name = name
614        self.property = property
615        self.index = 1
616
617    def add(self, name, x, y, w, h, text, value = None):
618        if value is None:
619            value = name
620        add_data(self.dlg.db, "RadioButton",
621                 [(self.property, self.index, value,
622                   x, y, w, h, text, None)])
623        self.index += 1
624
625class Dialog:
626    def __init__(self, db, name, x, y, w, h, attr, title, first, default, cancel):
627        self.db = db
628        self.name = name
629        self.x, self.y, self.w, self.h = x,y,w,h
630        add_data(db, "Dialog", [(name, x,y,w,h,attr,title,first,default,cancel)])
631
632    def control(self, name, type, x, y, w, h, attr, prop, text, next, help):
633        add_data(self.db, "Control",
634                 [(self.name, name, type, x, y, w, h, attr, prop, text, next, help)])
635        return Control(self, name)
636
637    def text(self, name, x, y, w, h, attr, text):
638        return self.control(name, "Text", x, y, w, h, attr, None,
639                     text, None, None)
640
641    def bitmap(self, name, x, y, w, h, text):
642        return self.control(name, "Bitmap", x, y, w, h, 1, None, text, None, None)
643
644    def line(self, name, x, y, w, h):
645        return self.control(name, "Line", x, y, w, h, 1, None, None, None, None)
646
647    def pushbutton(self, name, x, y, w, h, attr, text, next):
648        return self.control(name, "PushButton", x, y, w, h, attr, None, text, next, None)
649
650    def radiogroup(self, name, x, y, w, h, attr, prop, text, next):
651        add_data(self.db, "Control",
652                 [(self.name, name, "RadioButtonGroup",
653                   x, y, w, h, attr, prop, text, next, None)])
654        return RadioButtonGroup(self, name, prop)
655
656    def checkbox(self, name, x, y, w, h, attr, prop, text, next):
657        return self.control(name, "CheckBox", x, y, w, h, attr, prop, text, next, None)
658
659def pe_type(path):
660    header = open(path, "rb").read(1000)
661    # offset of PE header is at offset 0x3c
662    pe_offset = struct.unpack("<i", header[0x3c:0x40])[0]
663    assert header[pe_offset:pe_offset+4] == "PE\0\0"
664    machine = struct.unpack("<H", header[pe_offset+4:pe_offset+6])[0]
665    return machine
666
667def set_arch_from_file(path):
668    global msi_type, Win64, arch_ext
669    machine = pe_type(path)
670    if machine == 0x14c:
671        # i386
672        msi_type = "Intel"
673        Win64 = 0
674        arch_ext = ''
675    elif machine == 0x200:
676        # Itanium
677        msi_type = "Intel64"
678        Win64 = 1
679        arch_ext = '.ia64'
680    elif machine == 0x8664:
681        # AMD64
682        msi_type = "x64"
683        Win64 = 1
684        arch_ext = '.amd64'
685    else:
686        raise ValueError, "Unsupported architecture"
687    msi_type += ";1033"
688