#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright 2010-2023 The Mumble Developers. All rights reserved.
# Use of this source code is governed by a BSD-style license
# that can be found in the LICENSE file at the root of the
# Mumble source tree or at <https://www.mumble.info/LICENSE>.

#
# Simple Mac OS X Application Bundler for Mumble
#
# Loosely based on original bash-version by Sebastian Schlingmann (based, again, on a OSX application bundler
# by Thomas Keller).
#

import sys, os, string, shutil, plistlib, tempfile, glob
from subprocess import Popen, PIPE
import argparse

options = None

def gitrev():
	'''Get git revision of the current Mumble build.'''
	return os.popen('git describe').read()[:-1]

def certificate_subject_OU(identity):
	'''Extract the subject OU from the certificate matching the identity parameter in the specified keychain.'''

	findCert = Popen(('/usr/bin/security', 'find-certificate', '-c', identity, '-p', options.keychain), stdout=PIPE)
	pem, _ = findCert.communicate()

	openssl = Popen(('/usr/bin/openssl', 'x509', '-subject', '-noout'), stdout=PIPE, stdin=PIPE)
	subject, _ = openssl.communicate(pem)

	attr = '/OU='
	begin = subject.index(attr) + len(attr)
	tmp = subject[begin:]
	end = tmp.index('/')

	return tmp[:end]

def lookup_file_identifier(path):
	try:
		d = plistlib.load(open(os.path.join(path, 'Contents', 'Info.plist'), "rb"))
		return d['CFBundleIdentifier']
	except:
		return os.path.basename(path)

def codesign(path):
	'''Call the codesign executable on the signable object at path.'''

	certname = 'Developer ID Application: %s' % options.developer_id
	OU = certificate_subject_OU(certname)

	if hasattr(path, 'isalpha'):
		path = (path,)
	for p in path:
		identifier = lookup_file_identifier(p)
		reqs = None
		with open(os.path.join(options.source_dir, 'macx/scripts/codesign-requirements.tmpl'), 'r') as f:
			tmplReqs = f.read()
			reqs = string.Template(tmplReqs).substitute({
				'identifier': identifier,
				'subject_OU': OU,
			})
		p = Popen(('codesign', '--keychain', options.keychain, '-vvvv', '-i', identifier, '-r='+reqs, '-s', certname, p))
		retval = p.wait()
		if retval != 0:
			return retval
	return 0

def prodsign(inf, outf):
	'''Call the prodsign executable.'''

	certname = 'Developer ID Installer: %s' % options.developer_id
	p = Popen(('productsign', '--keychain', options.keychain, '--sign', certname, inf, outf))
	retval = p.wait()
	if retval != 0:
		return retval
	return 0

def create_overlay_package():
	print('* Creating overlay installer')

	bundle = os.path.join(options.binary_dir, 'MumbleOverlay.osax')
	overlaylib = os.path.join(options.binary_dir, 'libmumbleoverlay.dylib')
	if options.developer_id:
		codesign(bundle)
		codesign(overlaylib)

	# Used as background in the installer
	shutil.copy(os.path.join(options.source_dir, 'icons/mumble.osx.installer.png'), os.path.join(options.binary_dir, 'bg.png'))

	p = Popen(os.path.join(os.path.abspath(options.source_dir), 'macx/scripts/build-overlay-installer'), cwd=options.binary_dir)
	retval = p.wait()
	if retval != 0:
		raise Exception('build-overlay-installer failed')
	if options.developer_id:
		os.rename(os.path.join(options.binary_dir, 'MumbleOverlay.pkg'), os.path.join(options.binary_dir, 'MumbleOverlayUnsigned.pkg'))
		prodsign(os.path.join(options.binary_dir, 'MumbleOverlayUnsigned.pkg'), os.path.join(options.binary_dir, 'MumbleOverlay.pkg'))

class AppBundle(object):
	def copy_helper(self, fn):
		'''
			Copy a helper binary into the Mumble app bundle.
		'''
		if os.path.exists(os.path.join(self.bundle, '..', fn)):
			print(' * Copying helper binary: %s' % fn)
			src = os.path.join(self.bundle, '..', fn)
			dst = os.path.join(self.bundle, 'Contents', 'MacOS', fn)
			shutil.copy(src, dst)

	def copy_resources(self, rsrcs):
		'''
			Copy needed resources into our bundle.
		'''
		print(' * Copying needed resources')
		rsrcpath = os.path.join(self.bundle, 'Contents', 'Resources')
		if not os.path.exists(rsrcpath):
			os.mkdir(rsrcpath)

		# Copy resources already in the bundle
		for rsrc in rsrcs:
			b = os.path.basename(rsrc)
			if os.path.isdir(rsrc):
				shutil.copytree(rsrc, os.path.join(rsrcpath, b), symlinks=True)
			elif os.path.isfile(rsrc):
				shutil.copy(rsrc, os.path.join(rsrcpath, b))

		# Extras
		if not options.no_overlay:
			shutil.copy(os.path.join(options.binary_dir, 'MumbleOverlay.pkg'), os.path.join(rsrcpath, 'MumbleOverlay.pkg'))

	def copy_codecs(self):
		'''
			Try to copy the dynamic audio codec libraries into the App Bundle.
		'''
		print(' * Attempting to copy audio codec libraries into App Bundle')
		dst = os.path.join(self.bundle, 'Contents', 'Codecs')
		if not os.path.exists(dst):
			os.makedirs(dst)
		codecs = (os.path.join(options.binary_dir, 'libcelt0.0.7.0.dylib'), os.path.join(options.binary_dir, 'libopus.dylib'))
		for codec in codecs:
			if os.path.exists(codec):
				shutil.copy(codec, dst)

	def copy_plugins(self):
		'''
			Copy over any built Mumble plugins.
		'''
		print(' * Copying (positional audio) plugins')
		dst = os.path.join(self.bundle, 'Contents', 'Plugins')
		if not os.path.exists(dst):
			os.makedirs(dst)
		for plugin in glob.glob(os.path.join(options.binary_dir, 'plugins') + '/*.dylib'):
			shutil.copy(plugin, dst)

	def update_plist(self):
		'''
			Modify our bundle's Info.plist to make it ready for release.
		'''
		if self.version is not None:
			print(' * Changing version in Info.plist')
			p = self.infoplist
			p['CFBundleVersion'] = self.version
			p['CFBundleShortVersionString'] = self.version
			plistlib.dump(p, open(self.infopath, "wb"))

	def set_min_macosx_version(self, version):
		'''
			Set the minimum version of Mac OS X version that this App will run on.
		'''
		print(' * Setting minimum Mac OS X version to: %s' % (version))
		self.infoplist['LSMinimumSystemVersion'] = version

	def done(self):
		plistlib.dump(self.infoplist, open(self.infopath, "wb"))
		print(' * Done!')
		print()

	def __init__(self, bundle, version=None):
		self.framework_path = ''
		self.handled_libs = {}
		self.bundle = bundle
		self.version = version
		self.infopath = os.path.join(os.path.abspath(bundle), 'Contents', 'Info.plist')
		self.infoplist = plistlib.load(open(self.infopath, "rb"))
		self.binary = os.path.join(os.path.abspath(bundle), 'Contents', 'MacOS', self.infoplist['CFBundleExecutable'])
		print(' * Preparing AppBundle')


class FolderObject(object):

	class Exception(Exception):
		pass

	def __init__(self):
		self.tmp = tempfile.mkdtemp()

	def copy(self, src, dst='/'):
		'''
			Copy a file or directory into the folder.
		'''
		asrc = os.path.abspath(src)

		if dst[0] != '/':
			raise self.Exception

		# Determine destination
		if dst[-1] == '/':
			adst = os.path.abspath(self.tmp + '/' + dst + os.path.basename(src))
		else:
			adst = os.path.abspath(self.tmp + '/' + dst)

		if os.path.isdir(asrc):
			print(' * Copying directory: %s' % os.path.basename(asrc))
			shutil.copytree(asrc, adst, symlinks=True)
		elif os.path.isfile(asrc):
			print(' * Copying file: %s' % os.path.basename(asrc))
			shutil.copy(asrc, adst)

	def symlink(self, src, dst):
		'''
			Create a symlink inside the folder.
		'''
		asrc = os.path.abspath(src)
		adst = self.tmp + '/' + dst
		print(' * Creating symlink %s' % os.path.basename(asrc))
		os.symlink(asrc, adst)

	def mkdir(self, name):
		'''
			Create a directory inside the folder.
		'''
		print(' * Creating directory %s' % os.path.basename(name))
		adst = self.tmp + '/'  + name
		os.makedirs(adst)


class DiskImage(FolderObject):

	def __init__(self, filename, volname):
		FolderObject.__init__(self)
		print(' * Preparing to create diskimage')
		self.filename = filename
		self.volname = volname

	def create(self):
		'''
			Create the disk image itself.
		'''
		print(' * Creating diskimage. Please wait...')
		if os.path.exists(self.filename):
			os.remove(self.filename)
		p = Popen(['hdiutil', 'create',
		           '-srcfolder', self.tmp,
		           '-format', 'UDBZ',
		           '-volname', self.volname,
		           '-megabytes', '130',
		           self.filename])
		retval = p.wait()
		assert retval == 0
		print(' * Removing temporary directory.')
		shutil.rmtree(self.tmp)
		print(' * Done!')

def package_client():
	if options.version is not None:
		ver = options.version
	else:
		ver = gitrev()
	if options.universal:
		fn = os.path.join(options.binary_dir, 'Mumble-Universal-%s.dmg') % ver
		title = 'Mumble %s (Universal)' % ver
	else:
		fn = os.path.join(options.binary_dir, 'Mumble-%s.dmg') % ver
		title = 'Mumble %s' % ver

	if not options.no_overlay:
		# Fix overlay installer package
		create_overlay_package()
		if options.only_overlay:
			sys.exit(0)

	# Do the finishing touches to our Application bundle before release
	a = AppBundle(os.path.join(options.binary_dir, 'Mumble.app'), ver)
	a.copy_helper('mumble-g15-helper')
	a.copy_codecs()
	a.copy_plugins()
	a.copy_resources([os.path.join(options.source_dir, 'icons/mumble.icns')])
	a.update_plist()
	if not options.universal:
		a.set_min_macosx_version('10.9.0')
	else:
		a.set_min_macosx_version('10.4.8')
	a.done()

	# Sign our binaries, etc.
	if options.developer_id:
		print(' * Signing binaries with Developer ID `%s\'' % options.developer_id)
		binaries = (
			os.path.join(options.binary_dir, 'Mumble.app'),
			os.path.join(options.binary_dir, 'Mumble.app/Contents/Plugins/liblink.dylib'),
			os.path.join(options.binary_dir, 'Mumble.app/Contents/Plugins/libmanual.dylib'),
			os.path.join(options.binary_dir, 'Mumble.app/Contents/Codecs/libcelt0.0.7.0.dylib'),
			os.path.join(options.binary_dir, 'Mumble.app/Contents/Codecs/libopus.dylib'),
			os.path.join(options.binary_dir, 'Mumble.app/Contents/MacOS/mumble-g15-helper'),
		)
		availableBinaries = [bin for bin in binaries if os.path.exists(bin)]
		codesign(availableBinaries)
		print()

	if options.only_appbundle:
		sys.exit(0)

	# Create diskimage
	d = DiskImage(fn, title)
	d.copy(os.path.join(options.source_dir, 'macx/scripts/DS_Store'), '/.DS_Store')
	d.symlink('/Applications', '/Applications')
	d.copy(os.path.join(options.binary_dir, 'Mumble.app'))
	d.create()

def package_server():
	if options.version is not None:
		ver = options.version
	else:
		ver = gitrev()

	name = 'Murmur-OSX-Static-%s' % ver

	# Fix .ini files
	p = Popen(('bash', 'mkini.sh'), cwd=os.path.join(options.source_dir, 'scripts'))
	retval = p.wait()
	if retval != 0:
		raise Exception('mkini.sh failed')

	destdir = os.path.join(options.binary_dir, name)
	if os.path.exists(destdir):
		shutil.rmtree(destdir)
	os.mkdir(destdir)

	shutil.copy(os.path.join(options.binary_dir, 'installer/gpl.txt'), os.path.join(destdir, 'LICENSE'))
	shutil.copy(os.path.join(options.binary_dir, 'docs/additional-readmes/README.static.osx'), os.path.join(destdir, 'README'))
	shutil.copy(os.path.join(options.binary_dir, 'CHANGES'), os.path.join(destdir, 'CHANGES'))
	shutil.copy(os.path.join(options.binary_dir, 'scripts/murmur.pl'), os.path.join(destdir, 'murmur.pl'))
	shutil.copy(os.path.join(options.binary_dir, 'scripts/weblist.pl'), os.path.join(destdir, 'weblist.pl'))
	shutil.copy(os.path.join(options.binary_dir, 'scripts/icedemo.php'), os.path.join(destdir, 'icedemo.php'))
	shutil.copy(os.path.join(options.binary_dir, 'scripts/weblist.php'), os.path.join(destdir, 'weblist.php'))
	shutil.copy(os.path.join(options.binary_dir, 'scripts/murmur.ini.osx'), os.path.join(destdir, 'murmur.ini'))
	shutil.copy(os.path.join(options.binary_dir, 'src/murmur/Murmur.ice'), os.path.join(destdir, 'Murmur.ice'))

	shutil.copy(os.path.join(options.binary_dir, 'murmurd'), os.path.join(destdir, 'murmurd'))
	codesign(os.path.join(destdir, 'murmurd'))

	certname = 'Developer ID Installer: %s' % options.developer_id
	p = Popen(('xip', '--keychain', options.keychain, '-s', certname, '--timestamp', destdir, os.path.join(options.binary_dir, name+'.xip')))
	retval = p.wait()
	if retval != 0:
		print('Failed to build Murmur XIP package')
		sys.exit(1)

	absrelease = os.path.join(os.getcwd(), options.binary_dir)

	p = Popen(('tar', '-cjpf', name+'.tar.bz2', name), cwd=absrelease)
	retval = p.wait()
	if retval != 0:
		print('Failed to build Murmur tar.bz2 package')
		sys.exit(1)

	p = Popen(('gpg', '--detach-sign', '--armor', '-u', options.developer_id, '-o', name+'.tar.bz2.sig', name+'.tar.bz2'), cwd=absrelease)
	retval = p.wait()
	if retval != 0:
		print('Failed to sign Murmur tar.bz2 package')
		sys.exit(1)

if __name__ == '__main__':
	parser = argparse.ArgumentParser()
	parser.add_argument('--source-dir', help='This sets the path to the repository. (Defaults to ".")', default='.')
	parser.add_argument('--binary-dir', help='This sets the path to the folder containing binaries. It will also be used as output directory. (Defaults to "build")', default='build')
	parser.add_argument('--version', help='This overrides the version number of the build.')
	parser.add_argument('--universal', help='Build an universal snapshot.', action='store_true', default=False)
	parser.add_argument('--only-appbundle', help='Only prepare the appbundle. Do not package.', action='store_true', default=False)
	overlay_group = parser.add_mutually_exclusive_group()
	overlay_group.add_argument('--only-overlay', help='Only create the overlay installer.', action='store_true', default=False)
	overlay_group.add_argument('--no-overlay', help='Skip bundling the overlay', action='store_true', default=False)
	parser.add_argument('--developer-id', help='Identity (Developer ID) to use for code signing. The name is also used for GPG signing. (If not set, no code signing will occur)')
	parser.add_argument('--keychain', help='The keychain to use when invoking code signing utilities. (Defaults to "login.keychain")', default='login.keychain')
	parser.add_argument('--server', help='Build a Murmur package.', action='store_true', default=False)

	options = parser.parse_args()

	if not os.path.exists(options.source_dir):
		print('Please specify a source directory that exists!')
		sys.exit(1)

	if not os.path.exists(options.binary_dir):
		print('Please specify a binary directory that exists!')
		sys.exit(1)

	if not options.server:
		package_client()
	else:
		package_server()
