| 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) |
|---|