|
1 #!/usr/bin/env python |
|
2 # -*- coding: utf-8 -*- |
|
3 |
|
4 ############################################################################## |
|
5 # cmd_simplesis.py - Ensymble command line tool, simplesis command |
|
6 # Copyright 2006, 2007, 2008, 2009 Jussi Ylänen |
|
7 # |
|
8 # This file is part of Ensymble developer utilities for Symbian OS(TM). |
|
9 # |
|
10 # Ensymble is free software; you can redistribute it and/or modify |
|
11 # it under the terms of the GNU General Public License as published by |
|
12 # the Free Software Foundation; either version 2 of the License, or |
|
13 # (at your option) any later version. |
|
14 # |
|
15 # Ensymble is distributed in the hope that it will be useful, |
|
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
18 # GNU General Public License for more details. |
|
19 # |
|
20 # You should have received a copy of the GNU General Public License |
|
21 # along with Ensymble; if not, write to the Free Software |
|
22 # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA |
|
23 ############################################################################## |
|
24 |
|
25 import sys |
|
26 import os |
|
27 import re |
|
28 import getopt |
|
29 import getpass |
|
30 import locale |
|
31 import struct |
|
32 import zlib |
|
33 |
|
34 import sisfile |
|
35 import sisfield |
|
36 import symbianutil |
|
37 import rscfile |
|
38 import miffile |
|
39 |
|
40 |
|
41 ############################################################################## |
|
42 # Help texts |
|
43 ############################################################################## |
|
44 |
|
45 shorthelp = 'Create a SIS package from a directory structure' |
|
46 longhelp = '''simplesis |
|
47 [--uid=0x01234567] [--version=1.0.0] [--lang=EN,...] |
|
48 [--caption="Package Name",...] [--drive=C] [--textfile=mytext_%C.txt] |
|
49 [--cert=mycert.cer] [--privkey=mykey.key] [--passphrase=12345] |
|
50 [--vendor="Vendor Name",...] [--encoding=terminal,filesystem] |
|
51 [--verbose] |
|
52 <srcdir> [sisfile] |
|
53 |
|
54 Create a SIS package from a directory structure. Only supports very |
|
55 simple SIS files. There is no support for conditionally included |
|
56 files, dependencies etc. |
|
57 |
|
58 Options: |
|
59 srcdir - Source directory |
|
60 sisfile - Path of the created SIS file |
|
61 uid - Symbian OS UID for the SIS package |
|
62 version - SIS package version: X.Y.Z or X,Y,Z (major, minor, build) |
|
63 lang - Comma separated list of two-character language codes |
|
64 caption - Comma separated list of package names in all languages |
|
65 drive - Drive where the package will be installed (any by default) |
|
66 textfile - Text file (or pattern, see below) to display during install |
|
67 cert - Certificate to use for signing (PEM format) |
|
68 privkey - Private key of the certificate (PEM format) |
|
69 passphrase - Pass phrase of the private key (insecure, use stdin instead) |
|
70 vendor - Vendor name or a comma separated list of names in all lang. |
|
71 encoding - Local character encodings for terminal and filesystem |
|
72 verbose - Print extra statistics |
|
73 |
|
74 If no certificate and its private key are given, a default self-signed |
|
75 certificate is used to sign the SIS file. Software authors are encouraged |
|
76 to create their own unique certificates for SIS packages that are to be |
|
77 distributed. |
|
78 |
|
79 Text to display uses UTF-8 encoding. The file name may contain formatting |
|
80 characters that are substituted for each selected language. If no formatting |
|
81 characters are present, the same text will be used for all languages. |
|
82 |
|
83 %% - literal % |
|
84 %n - language number (01 - 99) |
|
85 %c - two-character language code in lowercase letters |
|
86 %C - two-character language code in capital letters |
|
87 %l - language name in English, using only lowercase letters |
|
88 %l - language name in English, using mixed case letters |
|
89 ''' |
|
90 |
|
91 |
|
92 ############################################################################## |
|
93 # Parameters |
|
94 ############################################################################## |
|
95 |
|
96 MAXPASSPHRASELENGTH = 256 |
|
97 MAXCERTIFICATELENGTH = 65536 |
|
98 MAXPRIVATEKEYLENGTH = 65536 |
|
99 MAXFILESIZE = 1024 * 1024 * 8 # Eight megabytes |
|
100 MAXTEXTFILELENGTH = 1024 |
|
101 |
|
102 |
|
103 ############################################################################## |
|
104 # Global variables |
|
105 ############################################################################## |
|
106 |
|
107 debug = False |
|
108 |
|
109 |
|
110 ############################################################################## |
|
111 # Public module-level functions |
|
112 ############################################################################## |
|
113 |
|
114 def run(pgmname, argv): |
|
115 global debug |
|
116 |
|
117 # Determine system character encodings. |
|
118 try: |
|
119 # getdefaultlocale() may sometimes return None. |
|
120 # Fall back to ASCII encoding in that case. |
|
121 terminalenc = locale.getdefaultlocale()[1] + "" |
|
122 except TypeError: |
|
123 # Invalid locale, fall back to ASCII terminal encoding. |
|
124 terminalenc = "ascii" |
|
125 |
|
126 try: |
|
127 # sys.getfilesystemencoding() was introduced in Python v2.3 and |
|
128 # it can sometimes return None. Fall back to ASCII if something |
|
129 # goes wrong. |
|
130 filesystemenc = sys.getfilesystemencoding() + "" |
|
131 except (AttributeError, TypeError): |
|
132 filesystemenc = "ascii" |
|
133 |
|
134 try: |
|
135 gopt = getopt.gnu_getopt |
|
136 except: |
|
137 # Python <v2.3, GNU-style parameter ordering not supported. |
|
138 gopt = getopt.getopt |
|
139 |
|
140 # Parse command line arguments. |
|
141 short_opts = "u:r:l:c:f:t:a:k:p:d:e:vh" |
|
142 long_opts = [ |
|
143 "uid=", "version=", "lang=", "caption=", |
|
144 "drive=", "textfile=", "cert=", "privkey=", "passphrase=", "vendor=", |
|
145 "encoding=", "verbose", "debug", "help" |
|
146 ] |
|
147 args = gopt(argv, short_opts, long_opts) |
|
148 |
|
149 opts = dict(args[0]) |
|
150 pargs = args[1] |
|
151 |
|
152 if len(pargs) == 0: |
|
153 raise ValueError("no source file name given") |
|
154 |
|
155 # Override character encoding of command line and filesystem. |
|
156 encs = opts.get("--encoding", opts.get("-e", "%s,%s" % (terminalenc, |
|
157 filesystemenc))) |
|
158 try: |
|
159 terminalenc, filesystemenc = encs.split(",") |
|
160 except (ValueError, TypeError): |
|
161 raise ValueError("invalid encoding string '%s'" % encs) |
|
162 |
|
163 # Get source directory name. |
|
164 src = pargs[0].decode(terminalenc).encode(filesystemenc) |
|
165 if os.path.isdir(src): |
|
166 # Remove trailing slashes (or whatever the separator is). |
|
167 src = os.path.split(src + os.sep)[0] |
|
168 |
|
169 # Use last directory component as the name. |
|
170 basename = os.path.basename(src) |
|
171 |
|
172 # Source is a directory, recursively collect files it contains. |
|
173 srcdir = src |
|
174 srcfiles = [] |
|
175 prefixlen = len(srcdir) + len(os.sep) |
|
176 def getfiles(arg, dirname, names): |
|
177 for name in names: |
|
178 path = os.path.join(dirname, name) |
|
179 if not os.path.isdir(path): |
|
180 arg.append(path[prefixlen:]) |
|
181 os.path.walk(srcdir, getfiles, srcfiles) |
|
182 else: |
|
183 raise ValueError("%s: not a directory" % src) |
|
184 |
|
185 # Parse version string, use 1.0.0 by default. |
|
186 version = opts.get("--version", opts.get("-r", None)) |
|
187 if version == None: |
|
188 version = "1.0.0" |
|
189 print ("%s: warning: no package version given, " |
|
190 "using %s" % (pgmname, version)) |
|
191 try: |
|
192 version = parseversion(version) |
|
193 except (ValueError, IndexError, TypeError): |
|
194 raise ValueError("invalid version string '%s'" % version) |
|
195 |
|
196 # Determine output SIS file name. |
|
197 if len(pargs) == 1: |
|
198 # Derive output file name from input file name. |
|
199 outfile = "%s_v%d_%d_%d.sis" % (basename, version[0], |
|
200 version[1], version[2]) |
|
201 elif len(pargs) == 2: |
|
202 outfile = pargs[1].decode(terminalenc).encode(filesystemenc) |
|
203 if os.path.isdir(outfile): |
|
204 # Output to directory, derive output name from input file name. |
|
205 outfile = os.path.join(outfile, "%s_v%d_%d_%d.sis" % ( |
|
206 basename, version[0], version[1], version[2])) |
|
207 if not outfile.lower().endswith(".sis"): |
|
208 outfile += ".sis" |
|
209 else: |
|
210 raise ValueError("wrong number of arguments") |
|
211 |
|
212 # Auto-generate a test-range UID from basename. |
|
213 autouid = symbianutil.uidfromname(basename.decode(filesystemenc)) |
|
214 |
|
215 # Get package UID. |
|
216 puid = opts.get("--uid", opts.get("-u", None)) |
|
217 if puid == None: |
|
218 # No UID given, use auto-generated UID. |
|
219 puid = autouid |
|
220 print ("%s: warning: no UID given, using auto-generated " |
|
221 "test-range UID 0x%08x" % (pgmname, puid)) |
|
222 elif puid.lower().startswith("0x"): |
|
223 # Prefer hex UIDs with leading "0x". |
|
224 puid = long(puid, 16) |
|
225 else: |
|
226 try: |
|
227 if len(puid) == 8: |
|
228 # Assuming hex UID even without leading "0x". |
|
229 print ('%s: warning: assuming hex UID even ' |
|
230 'without leading "0x"' % pgmname) |
|
231 puid = long(puid, 16) |
|
232 else: |
|
233 # Decimal UID. |
|
234 puid = long(puid) |
|
235 print ('%s: warning: decimal UID converted to 0x%08x' % |
|
236 (pgmname, puid)) |
|
237 except ValueError: |
|
238 raise ValueError("invalid UID string '%s'" % puid) |
|
239 |
|
240 # Warn against specifying a test-range UID manually. |
|
241 if puid & 0xf0000000L == 0xe0000000L and puid != autouid: |
|
242 print ("%s: warning: manually specifying a test-range UID is " |
|
243 "not recommended" % pgmname) |
|
244 |
|
245 # Determine package language(s), use "EN" by default. |
|
246 lang = opts.get("--lang", opts.get("-l", "EN")).split(",") |
|
247 numlang = len(lang) |
|
248 |
|
249 # Verify that the language codes are correct. |
|
250 for l in lang: |
|
251 try: |
|
252 symbianutil.langidtonum[l] |
|
253 except KeyError: |
|
254 raise ValueError("%s: no such language code" % l) |
|
255 |
|
256 # Determine package caption(s), use basename by default. |
|
257 caption = opts.get("--caption", opts.get("-c", "")) |
|
258 caption = caption.decode(terminalenc) |
|
259 if len(caption) == 0: |
|
260 # Caption not given, use basename. |
|
261 caption = [basename] * numlang |
|
262 else: |
|
263 caption = caption.split(",") |
|
264 |
|
265 # Compare the number of languages and captions. |
|
266 if len(caption) != numlang: |
|
267 raise ValueError("invalid number of captions") |
|
268 |
|
269 # Determine installation drive, any by default. |
|
270 drive = opts.get("--drive", opts.get("-f", "any")).upper() |
|
271 if drive == "ANY" or drive == "!": |
|
272 drive = "!" |
|
273 elif drive != "C" and drive != "E": |
|
274 raise ValueError("%s: invalid drive letter" % drive) |
|
275 |
|
276 # Determine vendor name(s), use "Ensymble" by default. |
|
277 vendor = opts.get("--vendor", opts.get("-d", "Ensymble")) |
|
278 vendor = vendor.decode(terminalenc) |
|
279 vendor = vendor.split(",") |
|
280 if len(vendor) == 1: |
|
281 # Only one vendor name given, use it for all languages. |
|
282 vendor = vendor * numlang |
|
283 elif len(vendor) != numlang: |
|
284 raise ValueError("invalid number of vendor names") |
|
285 |
|
286 # Load text files. |
|
287 texts = [] |
|
288 textfile = opts.get("--textfile", opts.get("-t", None)) |
|
289 if textfile != None: |
|
290 texts = readtextfiles(textfile, lang) |
|
291 |
|
292 # Get certificate and its private key file names. |
|
293 cert = opts.get("--cert", opts.get("-a", None)) |
|
294 privkey = opts.get("--privkey", opts.get("-k", None)) |
|
295 if cert != None and privkey != None: |
|
296 # Convert file names from terminal encoding to filesystem encoding. |
|
297 cert = cert.decode(terminalenc).encode(filesystemenc) |
|
298 privkey = privkey.decode(terminalenc).encode(filesystemenc) |
|
299 |
|
300 # Read certificate file. |
|
301 f = file(cert, "rb") |
|
302 certdata = f.read(MAXCERTIFICATELENGTH + 1) |
|
303 f.close() |
|
304 |
|
305 if len(certdata) > MAXCERTIFICATELENGTH: |
|
306 raise ValueError("certificate file too large") |
|
307 |
|
308 # Read private key file. |
|
309 f = file(privkey, "rb") |
|
310 privkeydata = f.read(MAXPRIVATEKEYLENGTH + 1) |
|
311 f.close() |
|
312 |
|
313 if len(privkeydata) > MAXPRIVATEKEYLENGTH: |
|
314 raise ValueError("private key file too large") |
|
315 elif cert == None and privkey == None: |
|
316 # No certificate given, use the Ensymble default certificate. |
|
317 # defaultcert.py is not imported when not needed. This speeds |
|
318 # up program start-up a little. |
|
319 import defaultcert |
|
320 certdata = defaultcert.cert |
|
321 privkeydata = defaultcert.privkey |
|
322 |
|
323 print ("%s: warning: no certificate given, using " |
|
324 "insecure built-in one" % pgmname) |
|
325 |
|
326 # Warn if the UID is in the protected range. |
|
327 # Resulting SIS file will probably not install. |
|
328 if puid < 0x80000000L: |
|
329 print ("%s: warning: UID is in the protected range " |
|
330 "(0x00000000 - 0x7ffffff)" % pgmname) |
|
331 else: |
|
332 raise ValueError("missing certificate or private key") |
|
333 |
|
334 # Get pass phrase. Pass phrase remains in terminal encoding. |
|
335 passphrase = opts.get("--passphrase", opts.get("-p", None)) |
|
336 if passphrase == None and privkey != None: |
|
337 # Private key given without "--passphrase" option, ask it. |
|
338 if sys.stdin.isatty(): |
|
339 # Standard input is a TTY, ask password interactively. |
|
340 passphrase = getpass.getpass("Enter private key pass phrase:") |
|
341 else: |
|
342 # Not connected to a TTY, read stdin non-interactively instead. |
|
343 passphrase = sys.stdin.read(MAXPASSPHRASELENGTH + 1) |
|
344 |
|
345 if len(passphrase) > MAXPASSPHRASELENGTH: |
|
346 raise ValueError("pass phrase too long") |
|
347 |
|
348 passphrase = passphrase.strip() |
|
349 |
|
350 # Determine verbosity. |
|
351 verbose = False |
|
352 if "--verbose" in opts.keys() or "-v" in opts.keys(): |
|
353 verbose = True |
|
354 |
|
355 # Determine if debug output is requested. |
|
356 if "--debug" in opts.keys(): |
|
357 debug = True |
|
358 |
|
359 # Enable debug output for OpenSSL-related functions. |
|
360 import cryptutil |
|
361 cryptutil.setdebug(True) |
|
362 |
|
363 # Ingredients for successful SIS generation: |
|
364 # |
|
365 # terminalenc Terminal character encoding (autodetected) |
|
366 # filesystemenc File system name encoding (autodetected) |
|
367 # basename Base for generated file names on host, filesystemenc encoded |
|
368 # srcdir Directory of source files, filesystemenc encoded |
|
369 # srcfiles List of filesystemenc encoded source file names in srcdir |
|
370 # outfile Output SIS file name, filesystemenc encoded |
|
371 # puid Package UID, long integer |
|
372 # version A triple-item tuple (major, minor, build) |
|
373 # lang List of two-character language codes, ASCII strings |
|
374 # caption List of Unicode package captions, one per language |
|
375 # drive Installation drive letter or "!" |
|
376 # textfile File name pattern of text file(s) to display during install |
|
377 # texts Actual texts to display during install, one per language |
|
378 # cert Certificate in PEM format |
|
379 # privkey Certificate private key in PEM format |
|
380 # passphrase Pass phrase of private key, terminalenc encoded string |
|
381 # vendor List of Unicode vendor names, one per language |
|
382 # verbose Boolean indicating verbose terminal output |
|
383 |
|
384 if verbose: |
|
385 print |
|
386 print "Input files %s" % " ".join( |
|
387 [s.decode(filesystemenc).encode(terminalenc) for s in srcfiles]) |
|
388 print "Output SIS file %s" % ( |
|
389 outfile.decode(filesystemenc).encode(terminalenc)) |
|
390 print "UID 0x%08x" % puid |
|
391 print "Version %d.%d.%d" % ( |
|
392 version[0], version[1], version[2]) |
|
393 print "Language(s) %s" % ", ".join(lang) |
|
394 print "Package caption(s) %s" % ", ".join( |
|
395 [s.encode(terminalenc) for s in caption]) |
|
396 print "Install drive %s" % ((drive == "!") and |
|
397 "<any>" or drive) |
|
398 print "Text file(s) %s" % ((textfile and |
|
399 textfile.decode(filesystemenc).encode(terminalenc)) or "<none>") |
|
400 print "Certificate %s" % ((cert and |
|
401 cert.decode(filesystemenc).encode(terminalenc)) or "<default>") |
|
402 print "Private key %s" % ((privkey and |
|
403 privkey.decode(filesystemenc).encode(terminalenc)) or "<default>") |
|
404 print "Vendor name(s) %s" % ", ".join( |
|
405 [s.encode(terminalenc) for s in vendor]) |
|
406 print |
|
407 |
|
408 # Generate SimpleSISWriter object. |
|
409 sw = sisfile.SimpleSISWriter(lang, caption, puid, version, |
|
410 vendor[0], vendor) |
|
411 |
|
412 # Add text file or files to the SIS object. Text dialog is |
|
413 # supposed to be displayed before anything else is installed. |
|
414 if len(texts) == 1: |
|
415 sw.addfile(texts[0], operation = sisfield.EOpText) |
|
416 elif len(texts) > 1: |
|
417 sw.addlangdepfile(texts, operation = sisfield.EOpText) |
|
418 |
|
419 # Add files to SIS object. |
|
420 sysbinprefix = os.path.join("sys", "bin", "") |
|
421 for srcfile in srcfiles: |
|
422 # Read file. |
|
423 f = file(os.path.join(srcdir, srcfile), "rb") |
|
424 string = f.read(MAXFILESIZE + 1) |
|
425 f.close() |
|
426 |
|
427 if len(string) > MAXFILESIZE: |
|
428 raise ValueError("input file too large") |
|
429 |
|
430 # Check if the file is an E32Image (EXE or DLL). |
|
431 caps = symbianutil.e32imagecaps(string) |
|
432 |
|
433 if caps != None and not srcfile.startswith(sysbinprefix): |
|
434 print ("%s: warning: %s is an E32Image (EXE or DLL) outside %s%s" % |
|
435 (pgmname, srcfile, os.sep, sysbinprefix)) |
|
436 |
|
437 # Add file to the SIS object. |
|
438 target = srcfile.decode(filesystemenc).replace(os.sep, "\\") |
|
439 sw.addfile(string, "%s:\\%s" % (drive, target), capabilities = caps) |
|
440 del string |
|
441 |
|
442 # Add target device dependency. |
|
443 sw.addtargetdevice(0x101f7961L, (0, 0, 0), None, |
|
444 ["Series60ProductID"] * numlang) |
|
445 |
|
446 # Add certificate. |
|
447 sw.addcertificate(privkeydata, certdata, passphrase) |
|
448 |
|
449 # Generate SIS file out of the SimpleSISWriter object. |
|
450 sw.tofile(outfile) |
|
451 |
|
452 |
|
453 ############################################################################## |
|
454 # Module-level functions which are normally only used by this module |
|
455 ############################################################################## |
|
456 |
|
457 def parseversion(version): |
|
458 '''Parse a version string: "v1.2.3" or similar. |
|
459 |
|
460 Initial "v" can optionally be a capital "V" or omitted altogether. Minor |
|
461 and build numbers can also be omitted. Separator can be a comma or a |
|
462 period.''' |
|
463 |
|
464 version = version.strip().lower() |
|
465 |
|
466 # Strip initial "v" or "V". |
|
467 if version[0] == "v": |
|
468 version = version[1:] |
|
469 |
|
470 if "." in version: |
|
471 parts = [int(n) for n in version.split(".")] |
|
472 else: |
|
473 parts = [int(n) for n in version.split(",")] |
|
474 |
|
475 # Allow missing minor and build numbers. |
|
476 parts.extend([0, 0]) |
|
477 |
|
478 return parts[0:3] |
|
479 |
|
480 def readtextfiles(pattern, languages): |
|
481 '''Read language dependent text files. |
|
482 |
|
483 Files are assumed to be in UTF-8 encoding and re-encoded |
|
484 in UCS-2 (UTF-16LE) for Symbian OS to display during installation.''' |
|
485 |
|
486 if "%" not in pattern: |
|
487 # Only one file, read it. |
|
488 filenames = [pattern] |
|
489 else: |
|
490 filenames = [] |
|
491 for langid in languages: |
|
492 langnum = symbianutil.langidtonum[langid] |
|
493 langname = symbianutil.langnumtoname[langnum] |
|
494 |
|
495 # Replace formatting characters in file name pattern. |
|
496 filename = pattern |
|
497 filename = filename.replace("%n", "%02d" % langnum) |
|
498 filename = filename.replace("%c", langid.lower()) |
|
499 filename = filename.replace("%C", langid.upper()) |
|
500 filename = filename.replace("%l", langname.lower()) |
|
501 filename = filename.replace("%L", langname) |
|
502 filename = filename.replace("%%", "%") |
|
503 |
|
504 filenames.append(filename) |
|
505 |
|
506 texts = [] |
|
507 |
|
508 for filename in filenames: |
|
509 f = file(filename, "r") # Read as text. |
|
510 text = f.read(MAXTEXTFILELENGTH + 1) |
|
511 f.close() |
|
512 |
|
513 if len(text) > MAXTEXTFILELENGTH: |
|
514 raise ValueError("%s: text file too large" % filename) |
|
515 |
|
516 texts.append(text.decode("UTF-8").encode("UTF-16LE")) |
|
517 |
|
518 return texts |