1 | # This Source Code Form is subject to the terms of the Mozilla Public |
---|
2 | # License, v. 2.0. If a copy of the MPL was not distributed with this |
---|
3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. |
---|
4 | |
---|
5 | import errno |
---|
6 | import glob |
---|
7 | import io |
---|
8 | import json |
---|
9 | import os |
---|
10 | import re |
---|
11 | import struct |
---|
12 | import subprocess |
---|
13 | import sys |
---|
14 | import random |
---|
15 | import posixpath |
---|
16 | |
---|
17 | from packager import (readMetadata, getDefaultFileName, getBuildVersion, |
---|
18 | getTemplate, get_extension, Files, get_app_id) |
---|
19 | |
---|
20 | defaultLocale = 'en_US' |
---|
21 | |
---|
22 | |
---|
23 | def getIgnoredFiles(params): |
---|
24 | return {'store.description'} |
---|
25 | |
---|
26 | |
---|
27 | def getPackageFiles(params): |
---|
28 | result = {'_locales', 'icons', 'jquery-ui', 'lib', 'skin', 'ui', 'ext'} |
---|
29 | |
---|
30 | if params['devenv']: |
---|
31 | result.add('qunit') |
---|
32 | |
---|
33 | baseDir = params['baseDir'] |
---|
34 | |
---|
35 | for file in os.listdir(baseDir): |
---|
36 | if os.path.splitext(file)[1] in {'.json', '.js', '.html', '.xml'}: |
---|
37 | result.add(file) |
---|
38 | return result |
---|
39 | |
---|
40 | |
---|
41 | def processFile(path, data, params): |
---|
42 | # We don't change anything yet, this function currently only exists here so |
---|
43 | # that it can be overridden if necessary. |
---|
44 | return data |
---|
45 | |
---|
46 | |
---|
47 | def makeIcons(files, filenames): |
---|
48 | icons = {} |
---|
49 | for filename in filenames: |
---|
50 | try: |
---|
51 | magic, width, height = struct.unpack_from('>8s8xii', |
---|
52 | files[filename]) |
---|
53 | except struct.error: |
---|
54 | magic = None |
---|
55 | if magic != '\x89PNG\r\n\x1a\n': |
---|
56 | raise Exception(filename + ' is no valid PNG.') |
---|
57 | if width != height: |
---|
58 | print >>sys.stderr, 'Warning: %s size is %ix%i, icon should be square' % (filename, width, height) |
---|
59 | icons[width] = filename |
---|
60 | return icons |
---|
61 | |
---|
62 | |
---|
63 | def createScriptPage(params, template_name, script_option): |
---|
64 | template = getTemplate(template_name, autoEscape=True) |
---|
65 | return template.render( |
---|
66 | basename=params['metadata'].get('general', 'basename'), |
---|
67 | scripts=params['metadata'].get(*script_option).split() |
---|
68 | ).encode('utf-8') |
---|
69 | |
---|
70 | |
---|
71 | def createManifest(params, files): |
---|
72 | template = getTemplate('manifest.json.tmpl') |
---|
73 | templateData = dict(params) |
---|
74 | |
---|
75 | baseDir = templateData['baseDir'] |
---|
76 | metadata = templateData['metadata'] |
---|
77 | |
---|
78 | for opt in ('browserAction', 'pageAction'): |
---|
79 | if not metadata.has_option('general', opt): |
---|
80 | continue |
---|
81 | |
---|
82 | icons = metadata.get('general', opt).split() |
---|
83 | if not icons: |
---|
84 | continue |
---|
85 | |
---|
86 | if len(icons) == 1: |
---|
87 | # ... = icon.png |
---|
88 | icon, popup = icons[0], None |
---|
89 | elif icons[-1].endswith('.html'): |
---|
90 | if len(icons) == 2: |
---|
91 | # ... = icon.png popup.html |
---|
92 | icon, popup = icons |
---|
93 | else: |
---|
94 | # ... = icon-19.png icon-38.png popup.html |
---|
95 | popup = icons.pop() |
---|
96 | icon = makeIcons(files, icons) |
---|
97 | else: |
---|
98 | # ... = icon-16.png icon-32.png icon-48.png |
---|
99 | icon = makeIcons(files, icons) |
---|
100 | popup = None |
---|
101 | |
---|
102 | templateData[opt] = {'icon': icon, 'popup': popup} |
---|
103 | |
---|
104 | if metadata.has_option('general', 'icons'): |
---|
105 | templateData['icons'] = makeIcons(files, |
---|
106 | metadata.get('general', 'icons').split()) |
---|
107 | |
---|
108 | if metadata.has_option('general', 'permissions'): |
---|
109 | templateData['permissions'] = metadata.get('general', 'permissions').split() |
---|
110 | |
---|
111 | if metadata.has_option('general', 'optionalPermissions'): |
---|
112 | templateData['optionalPermissions'] = metadata.get( |
---|
113 | 'general', 'optionalPermissions').split() |
---|
114 | |
---|
115 | if metadata.has_option('general', 'backgroundScripts'): |
---|
116 | templateData['backgroundScripts'] = metadata.get( |
---|
117 | 'general', 'backgroundScripts').split() |
---|
118 | if params['devenv']: |
---|
119 | templateData['backgroundScripts'].append('devenvPoller__.js') |
---|
120 | |
---|
121 | if metadata.has_option('general', 'webAccessible') and metadata.get('general', 'webAccessible') != '': |
---|
122 | templateData['webAccessible'] = metadata.get('general', |
---|
123 | 'webAccessible').split() |
---|
124 | |
---|
125 | if metadata.has_option('general', 'externallyConnectable') and metadata.get('general', 'externallyConnectable') != '': |
---|
126 | templateData['externallyConnectable'] = metadata.get('general', 'externallyConnectable').split() |
---|
127 | |
---|
128 | if metadata.has_section('contentScripts'): |
---|
129 | contentScripts = [] |
---|
130 | for run_at, scripts in metadata.items('contentScripts'): |
---|
131 | if scripts == '': |
---|
132 | continue |
---|
133 | contentScripts.append({ |
---|
134 | 'matches': ['http://*/*', 'https://*/*'], |
---|
135 | 'js': scripts.split(), |
---|
136 | 'run_at': run_at, |
---|
137 | 'all_frames': True, |
---|
138 | 'match_about_blank': True, |
---|
139 | }) |
---|
140 | templateData['contentScripts'] = contentScripts |
---|
141 | if params['type'] == 'gecko': |
---|
142 | templateData['app_id'] = get_app_id(params['releaseBuild'], metadata) |
---|
143 | |
---|
144 | manifest = template.render(templateData) |
---|
145 | |
---|
146 | # Normalize JSON structure |
---|
147 | licenseComment = re.compile(r'/\*.*?\*/', re.S) |
---|
148 | data = json.loads(re.sub(licenseComment, '', manifest, 1)) |
---|
149 | if '_dummy' in data: |
---|
150 | del data['_dummy'] |
---|
151 | manifest = json.dumps(data, sort_keys=True, indent=2) |
---|
152 | |
---|
153 | return manifest.encode('utf-8') |
---|
154 | |
---|
155 | |
---|
156 | def toJson(data): |
---|
157 | return json.dumps( |
---|
158 | data, ensure_ascii=False, sort_keys=True, |
---|
159 | indent=2, separators=(',', ': ') |
---|
160 | ).encode('utf-8') + '\n' |
---|
161 | |
---|
162 | |
---|
163 | def create_bundles(params, files, bundle_tests): |
---|
164 | base_extension_path = params['baseDir'] |
---|
165 | info_templates = { |
---|
166 | 'chrome': 'chromeInfo.js.tmpl', |
---|
167 | 'edge': 'edgeInfo.js.tmpl', |
---|
168 | 'gecko': 'geckoInfo.js.tmpl' |
---|
169 | } |
---|
170 | |
---|
171 | # Historically we didn't use relative paths when requiring modules, so in |
---|
172 | # order for webpack to know where to find them we need to pass in a list of |
---|
173 | # resolve paths. Going forward we should always use relative paths, once we |
---|
174 | # do that consistently this can be removed. See issues 5760, 5761 and 5762. |
---|
175 | resolve_paths = [os.path.join(base_extension_path, dir, 'lib') |
---|
176 | for dir in ['', 'adblockpluscore', 'adblockplusui']] |
---|
177 | |
---|
178 | info_template = getTemplate(info_templates[params['type']]) |
---|
179 | info_module = info_template.render( |
---|
180 | basename=params['metadata'].get('general', 'basename'), |
---|
181 | version=params['metadata'].get('general', 'version') |
---|
182 | ).encode('utf-8') |
---|
183 | |
---|
184 | configuration = { |
---|
185 | 'bundles': [], |
---|
186 | 'extension_path': base_extension_path, |
---|
187 | 'info_module': info_module, |
---|
188 | 'resolve_paths': resolve_paths, |
---|
189 | } |
---|
190 | |
---|
191 | for item in params['metadata'].items('bundles'): |
---|
192 | name, value = item |
---|
193 | base_item_path = os.path.dirname(item.source) |
---|
194 | |
---|
195 | bundle_file = os.path.relpath(os.path.join(base_item_path, name), |
---|
196 | base_extension_path) |
---|
197 | entry_files = [os.path.join(base_item_path, module_path) |
---|
198 | for module_path in value.split()] |
---|
199 | configuration['bundles'].append({ |
---|
200 | 'bundle_name': bundle_file, |
---|
201 | 'entry_points': entry_files, |
---|
202 | }) |
---|
203 | |
---|
204 | if bundle_tests: |
---|
205 | qunit_path = os.path.join(base_extension_path, 'qunit') |
---|
206 | qunit_files = ([os.path.join(qunit_path, 'common.js')] + |
---|
207 | glob.glob(os.path.join(qunit_path, 'tests', '*.js'))) |
---|
208 | configuration['bundles'].append({ |
---|
209 | 'bundle_name': 'qunit/tests.js', |
---|
210 | 'entry_points': qunit_files |
---|
211 | }) |
---|
212 | |
---|
213 | cmd = ['node', os.path.join(os.path.dirname(__file__), 'webpack_runner.js')] |
---|
214 | process = subprocess.Popen(cmd, stdout=subprocess.PIPE, |
---|
215 | stdin=subprocess.PIPE) |
---|
216 | output = process.communicate(input=toJson(configuration))[0] |
---|
217 | if process.returncode != 0: |
---|
218 | raise subprocess.CalledProcessError(process.returncode, cmd=cmd) |
---|
219 | output = json.loads(output) |
---|
220 | |
---|
221 | # Clear the mapping for any files included in a bundle, to avoid them being |
---|
222 | # duplicated in the build. |
---|
223 | for to_ignore in output['included']: |
---|
224 | files.pop(to_ignore, None) |
---|
225 | |
---|
226 | for bundle in output['files']: |
---|
227 | files[bundle] = output['files'][bundle].encode('utf-8') |
---|
228 | |
---|
229 | |
---|
230 | def import_locales(params, files): |
---|
231 | for item in params['metadata'].items('import_locales'): |
---|
232 | filename = item[0] |
---|
233 | for sourceFile in glob.glob(os.path.join(os.path.dirname(item.source), |
---|
234 | *filename.split('/'))): |
---|
235 | keys = item[1] |
---|
236 | locale = sourceFile.split(os.path.sep)[-2] |
---|
237 | targetFile = posixpath.join('_locales', locale, 'messages.json') |
---|
238 | data = json.loads(files.get(targetFile, '{}').decode('utf-8')) |
---|
239 | |
---|
240 | try: |
---|
241 | with io.open(sourceFile, 'r', encoding='utf-8') as handle: |
---|
242 | sourceData = json.load(handle) |
---|
243 | |
---|
244 | # Resolve wildcard imports |
---|
245 | if keys == '*': |
---|
246 | importList = sourceData.keys() |
---|
247 | importList = filter(lambda k: not k.startswith('_'), importList) |
---|
248 | keys = ' '.join(importList) |
---|
249 | |
---|
250 | for stringID in keys.split(): |
---|
251 | if stringID in sourceData: |
---|
252 | if stringID in data: |
---|
253 | print ('Warning: locale string {} defined multiple' |
---|
254 | ' times').format(stringID) |
---|
255 | |
---|
256 | data[stringID] = sourceData[stringID] |
---|
257 | except Exception as e: |
---|
258 | print 'Warning: error importing locale data from %s: %s' % (sourceFile, e) |
---|
259 | |
---|
260 | files[targetFile] = toJson(data) |
---|
261 | |
---|
262 | |
---|
263 | def truncate(text, length_limit): |
---|
264 | if len(text) <= length_limit: |
---|
265 | return text |
---|
266 | return text[:length_limit - 1].rstrip() + u'\u2026' |
---|
267 | |
---|
268 | |
---|
269 | def fix_translations_for_chrome(files): |
---|
270 | defaults = {} |
---|
271 | data = json.loads(files['_locales/%s/messages.json' % defaultLocale]) |
---|
272 | for match in re.finditer(r'__MSG_(\S+)__', files['manifest.json']): |
---|
273 | name = match.group(1) |
---|
274 | defaults[name] = data[name] |
---|
275 | |
---|
276 | limits = {} |
---|
277 | manifest = json.loads(files['manifest.json']) |
---|
278 | for key, limit in (('name', 45), ('description', 132), ('short_name', 12)): |
---|
279 | match = re.search(r'__MSG_(\S+)__', manifest.get(key, '')) |
---|
280 | if match: |
---|
281 | limits[match.group(1)] = limit |
---|
282 | |
---|
283 | for path in list(files): |
---|
284 | match = re.search(r'^_locales/(?:es_(AR|CL|(MX))|[^/]+)/(.*)', path) |
---|
285 | if not match: |
---|
286 | continue |
---|
287 | |
---|
288 | # The Chrome Web Store requires messages used in manifest.json to |
---|
289 | # be present in all languages, and enforces length limits on |
---|
290 | # extension name and description. |
---|
291 | is_latam, is_mexican, filename = match.groups() |
---|
292 | if filename == 'messages.json': |
---|
293 | data = json.loads(files[path]) |
---|
294 | for name, info in defaults.iteritems(): |
---|
295 | data.setdefault(name, info) |
---|
296 | for name, limit in limits.iteritems(): |
---|
297 | info = data.get(name) |
---|
298 | if info: |
---|
299 | info['message'] = truncate(info['message'], limit) |
---|
300 | files[path] = toJson(data) |
---|
301 | |
---|
302 | # Chrome combines Latin American dialects of Spanish into es-419. |
---|
303 | if is_latam: |
---|
304 | data = files.pop(path) |
---|
305 | if is_mexican: |
---|
306 | files['_locales/es_419/' + filename] = data |
---|
307 | |
---|
308 | |
---|
309 | def signBinary(zipdata, keyFile): |
---|
310 | from Crypto.Hash import SHA |
---|
311 | from Crypto.PublicKey import RSA |
---|
312 | from Crypto.Signature import PKCS1_v1_5 |
---|
313 | |
---|
314 | try: |
---|
315 | with open(keyFile, 'rb') as file: |
---|
316 | key = RSA.importKey(file.read()) |
---|
317 | except IOError as e: |
---|
318 | if e.errno != errno.ENOENT: |
---|
319 | raise |
---|
320 | key = RSA.generate(2048) |
---|
321 | with open(keyFile, 'wb') as file: |
---|
322 | file.write(key.exportKey('PEM')) |
---|
323 | |
---|
324 | return PKCS1_v1_5.new(key).sign(SHA.new(zipdata)) |
---|
325 | |
---|
326 | |
---|
327 | def getPublicKey(keyFile): |
---|
328 | from Crypto.PublicKey import RSA |
---|
329 | with open(keyFile, 'rb') as file: |
---|
330 | return RSA.importKey(file.read()).publickey().exportKey('DER') |
---|
331 | |
---|
332 | |
---|
333 | def writePackage(outputFile, pubkey, signature, zipdata): |
---|
334 | if isinstance(outputFile, basestring): |
---|
335 | file = open(outputFile, 'wb') |
---|
336 | else: |
---|
337 | file = outputFile |
---|
338 | if pubkey != None and signature != None: |
---|
339 | file.write(struct.pack('<4sIII', 'Cr24', 2, len(pubkey), len(signature))) |
---|
340 | file.write(pubkey) |
---|
341 | file.write(signature) |
---|
342 | file.write(zipdata) |
---|
343 | |
---|
344 | |
---|
345 | def add_devenv_requirements(files, metadata, params): |
---|
346 | files.read( |
---|
347 | os.path.join(os.path.dirname(__file__), 'chromeDevenvPoller__.js'), |
---|
348 | relpath='devenvPoller__.js', |
---|
349 | ) |
---|
350 | files['devenvVersion__'] = str(random.random()) |
---|
351 | |
---|
352 | if metadata.has_option('general', 'testScripts'): |
---|
353 | files['qunit/index.html'] = createScriptPage( |
---|
354 | params, 'testIndex.html.tmpl', ('general', 'testScripts') |
---|
355 | ) |
---|
356 | |
---|
357 | |
---|
358 | def createBuild(baseDir, type='chrome', outFile=None, buildNum=None, releaseBuild=False, keyFile=None, devenv=False): |
---|
359 | metadata = readMetadata(baseDir, type) |
---|
360 | version = getBuildVersion(baseDir, metadata, releaseBuild, buildNum) |
---|
361 | |
---|
362 | if outFile == None: |
---|
363 | file_extension = get_extension(type, keyFile is not None) |
---|
364 | outFile = getDefaultFileName(metadata, version, file_extension) |
---|
365 | |
---|
366 | params = { |
---|
367 | 'type': type, |
---|
368 | 'baseDir': baseDir, |
---|
369 | 'releaseBuild': releaseBuild, |
---|
370 | 'version': version, |
---|
371 | 'devenv': devenv, |
---|
372 | 'metadata': metadata, |
---|
373 | } |
---|
374 | |
---|
375 | mapped = metadata.items('mapping') if metadata.has_section('mapping') else [] |
---|
376 | files = Files(getPackageFiles(params), getIgnoredFiles(params), |
---|
377 | process=lambda path, data: processFile(path, data, params)) |
---|
378 | |
---|
379 | files.readMappedFiles(mapped) |
---|
380 | files.read(baseDir, skip=[opt for opt, _ in mapped]) |
---|
381 | |
---|
382 | if metadata.has_section('bundles'): |
---|
383 | bundle_tests = devenv and metadata.has_option('general', 'testScripts') |
---|
384 | create_bundles(params, files, bundle_tests) |
---|
385 | |
---|
386 | if metadata.has_section('preprocess'): |
---|
387 | files.preprocess( |
---|
388 | [f for f, _ in metadata.items('preprocess')], |
---|
389 | {'needsExt': True} |
---|
390 | ) |
---|
391 | |
---|
392 | if metadata.has_section('import_locales'): |
---|
393 | import_locales(params, files) |
---|
394 | |
---|
395 | files['manifest.json'] = createManifest(params, files) |
---|
396 | if type == 'chrome': |
---|
397 | fix_translations_for_chrome(files) |
---|
398 | |
---|
399 | if devenv: |
---|
400 | add_devenv_requirements(files, metadata, params) |
---|
401 | |
---|
402 | zipdata = files.zipToString() |
---|
403 | signature = None |
---|
404 | pubkey = None |
---|
405 | if keyFile != None: |
---|
406 | signature = signBinary(zipdata, keyFile) |
---|
407 | pubkey = getPublicKey(keyFile) |
---|
408 | writePackage(outFile, pubkey, signature, zipdata) |
---|