calibre/bypy/macos/__main__.py

855 lines
34 KiB
Python

#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPLv3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
import errno
import glob
import json
import operator
import os
import plistlib
import runpy
import shutil
import stat
import subprocess
import sys
import tempfile
import time
import zipfile
from functools import partial, reduce
from itertools import repeat
from bypy.constants import OUTPUT_DIR, PREFIX, PYTHON, python_major_minor_version
from bypy.constants import SRC as CALIBRE_DIR
from bypy.freeze import extract_extension_modules, fix_pycryptodome, freeze_python, is_package_dir, path_to_freeze_dir
from bypy.pkgs.piper import copy_piper_dir
from bypy.utils import current_dir, get_arches_in_binary, mkdtemp, py_compile, timeit, walk
abspath, join, basename, dirname = os.path.abspath, os.path.join, os.path.basename, os.path.dirname
iv = globals()['init_env']
calibre_constants = iv['calibre_constants']
QT_DLLS, QT_PLUGINS, PYQT_MODULES = iv['QT_DLLS'], iv['QT_PLUGINS'], iv['PYQT_MODULES']
QT_MAJOR = iv['QT_MAJOR']
py_ver = '.'.join(map(str, python_major_minor_version()))
sign_app = runpy.run_path(join(dirname(abspath(__file__)), 'sign.py'))['sign_app']
QT_PREFIX = join(PREFIX, 'qt')
QT_FRAMEWORKS = [x.replace(f'{QT_MAJOR}', '') for x in QT_DLLS]
ENV = dict(
FONTCONFIG_PATH='@executable_path/../Resources/fonts',
FONTCONFIG_FILE='@executable_path/../Resources/fonts/fonts.conf',
SSL_CERT_FILE='@executable_path/../Resources/resources/mozilla-ca-certs.pem',
OPENSSL_ENGINES='@executable_path/../Frameworks/engines-3',
OPENSSL_MODULES='@executable_path/../Frameworks/ossl-modules',
)
APPNAME, VERSION = calibre_constants['appname'], calibre_constants['version']
basenames, main_modules, main_functions = calibre_constants['basenames'], calibre_constants['modules'], calibre_constants['functions']
ARCH_FLAGS = '-arch x86_64 -arch arm64'.split()
EXPECTED_ARCHES = {'x86_64', 'arm64'}
MINIMUM_SYSTEM_VERSION = '13.3.0'
def compile_launcher_lib(contents_dir, base, pyver, inc_dir):
print('\tCompiling calibre_launcher.dylib')
env, env_vals = [], []
for key, val in ENV.items():
env.append(f'"{key}"'), env_vals.append(f'"{val}"')
env = ','.join(env)
env_vals = ','.join(env_vals)
dest = join(contents_dir, 'Frameworks', 'calibre-launcher.dylib')
src = join(base, 'util.c')
cmd = gcc + ARCH_FLAGS + CFLAGS + '-Wall -dynamiclib -std=gnu99'.split() + [src] + \
['-I' + base] + '-DPY_VERSION_MAJOR={} -DPY_VERSION_MINOR={}'.format(*pyver.split('.')).split() + \
[f'-I{path_to_freeze_dir()}', f'-I{inc_dir}'] + \
[f'-DENV_VARS={env}', f'-DENV_VAR_VALS={env_vals}'] + \
['-I%s/python/Python.framework/Versions/Current/Headers' % PREFIX] + \
'-current_version 1.0 -compatibility_version 1.0'.split() + \
'-fvisibility=hidden -o'.split() + [dest]
# We need libxml2.dylib linked because Apple's system frameworks link
# against a system version. And libxml2 uses global variables so we get
# crashes if the system libxml2 is loaded. Loading plugins like the Qt
# ones or usbobserver causes it to be loaded. So pre-load our libxml2
# to avoid it.
cmd += ['-L', f'{PREFIX}/lib', '-l', 'xml2', '-l', 'xslt',] + \
['-install_name',
'@executable_path/../Frameworks/' + os.path.basename(dest)] + \
[('-F%s/python' % PREFIX), '-framework', 'Python', '-framework', 'CoreFoundation', '-headerpad_max_install_names']
# print('\t'+' '.join(cmd))
sys.stdout.flush()
subprocess.check_call(cmd)
return dest
gcc = os.environ.get('CC', 'clang').split()
CFLAGS = os.environ.get('CFLAGS', '-Os').split()
def compile_launchers(contents_dir, inc_dir, xprograms, pyver):
base = dirname(abspath(__file__))
lib = compile_launcher_lib(contents_dir, base, pyver, inc_dir)
src = join(base, 'launcher.c')
programs = [lib]
for program, x in xprograms.items():
module, func, ptype = x
print('\tCompiling', program)
out = join(contents_dir, 'MacOS', program)
programs.append(out)
is_gui = 'true' if ptype == 'gui' else 'false'
cmd = gcc + ARCH_FLAGS + CFLAGS + [
'-Wall', f'-DPROGRAM=L"{program}"', f'-DMODULE=L"{module}"', f'-DFUNCTION=L"{func}"', f'-DIS_GUI={is_gui}',
'-I' + base, src, lib, '-o', out, '-headerpad_max_install_names',
]
# print('\t'+' '.join(cmd))
sys.stdout.flush()
subprocess.check_call(cmd)
return programs
def flipwritable(fn, mode=None):
"""
Flip the writability of a file and return the old mode. Returns None
if the file is already writable.
"""
if os.access(fn, os.W_OK):
return None
old_mode = os.stat(fn).st_mode
os.chmod(fn, stat.S_IWRITE | old_mode)
return old_mode
def check_universal(path):
arches = get_arches_in_binary(path)
if arches != EXPECTED_ARCHES:
raise SystemExit(f'The file {path} is not a universal binary, it only has arches: {", ".join(arches)}')
STRIPCMD = ['/usr/bin/strip', '-x', '-S', '-']
def strip_files(files, argv_max=(256 * 1024)):
"""
Strip a list of files
"""
tostrip = [(fn, flipwritable(fn)) for fn in files if os.path.exists(fn)]
while tostrip:
cmd = list(STRIPCMD)
flips = []
pathlen = reduce(operator.add, [len(s) + 1 for s in cmd])
while pathlen < argv_max:
if not tostrip:
break
added, flip = tostrip.pop()
pathlen += len(added) + 1
cmd.append(added)
flips.append((added, flip))
else:
cmd.pop()
tostrip.append(flips.pop())
os.spawnv(os.P_WAIT, cmd[0], cmd)
for args in flips:
flipwritable(*args)
def flush(func):
def ff(*args, **kwargs):
sys.stdout.flush()
sys.stderr.flush()
ret = func(*args, **kwargs)
sys.stdout.flush()
sys.stderr.flush()
return ret
return ff
class Freeze:
FID = '@executable_path/../Frameworks'
def __init__(self, build_dir, ext_dir, inc_dir, test_runner, test_launchers=False, dont_strip=False, sign_installers=False, notarize=False):
self.build_dir = os.path.realpath(build_dir)
self.inc_dir = os.path.realpath(inc_dir)
self.sign_installers = sign_installers
self.notarize = notarize
self.ext_dir = os.path.realpath(ext_dir)
self.test_runner = test_runner
self.dont_strip = dont_strip
self.contents_dir = join(self.build_dir, 'Contents')
self.resources_dir = join(self.contents_dir, 'Resources')
self.frameworks_dir = join(self.contents_dir, 'Frameworks')
self.exe_dir = join(self.contents_dir, 'MacOS')
self.helpers_dir = join(self.contents_dir, 'utils.app', 'Contents', 'MacOS')
self.site_packages = join(self.resources_dir, 'Python', 'site-packages')
self.to_strip = []
self.warnings = []
self.run(test_launchers)
def run(self, test_launchers):
ret = 0
self.ext_map = {}
if not test_launchers:
if os.path.exists(self.build_dir):
shutil.rmtree(self.build_dir)
os.makedirs(self.build_dir)
self.create_skeleton()
self.create_plist()
self.add_python_framework()
self.add_site_packages()
self.add_stdlib()
self.add_qt_frameworks()
self.add_calibre_plugins()
self.add_podofo()
self.add_poppler()
self.add_imaging_libs()
self.add_fontconfig()
self.add_misc_libraries()
self.add_resources()
self.copy_site()
self.compile_py_modules()
self.create_exe()
if not test_launchers and not self.dont_strip:
self.strip_files()
if not test_launchers:
self.create_gui_apps()
self.run_tests()
ret = self.makedmg(self.build_dir, APPNAME + '-' + VERSION)
return ret
@flush
def run_tests(self):
self.test_runner(join(self.contents_dir, 'MacOS', 'calibre-debug'), self.contents_dir)
@flush
def add_resources(self):
shutil.copytree('resources', join(self.resources_dir,
'resources'))
@flush
def strip_files(self):
print('\nStripping files...')
strip_files(self.to_strip)
@flush
def create_exe(self):
print('\nCreating launchers')
programs = {}
progs = []
for x in ('console', 'gui'):
progs += list(zip(basenames[x], main_modules[x], main_functions[x], repeat(x)))
for program, module, func, ptype in progs:
programs[program] = (module, func, ptype)
programs = compile_launchers(self.contents_dir, self.inc_dir, programs, py_ver)
for out in programs:
self.fix_dependencies_in_lib(out)
@flush
def set_id(self, path_to_lib, new_id):
old_mode = flipwritable(path_to_lib)
subprocess.check_call(['install_name_tool', '-id', new_id, path_to_lib])
if old_mode is not None:
flipwritable(path_to_lib, old_mode)
@flush
def get_dependencies(self, path_to_lib):
install_name = subprocess.check_output(['otool', '-D', path_to_lib]).splitlines()[-1].strip()
raw = subprocess.check_output(['otool', '-L', path_to_lib]).decode('utf-8')
for line in raw.splitlines():
if 'compatibility' not in line or line.strip().endswith(':'):
continue
idx = line.find('(')
path = line[:idx].strip()
yield path, path == install_name
@flush
def get_local_dependencies(self, path_to_lib):
for x, is_id in self.get_dependencies(path_to_lib):
if x in ('libunrar.dylib', 'libstemmer.0.dylib', 'libstemmer.dylib', 'libjbig.2.1.dylib') and not is_id:
yield x, x, is_id
else:
for y in ('@rpath/', PREFIX + '/lib/', PREFIX + '/python/Python.framework/', PREFIX + '/ffmpeg/lib/'):
if x.startswith(y):
if y == PREFIX + '/python/Python.framework/':
y = PREFIX + '/python/'
yield x, x[len(y):], is_id
break
@flush
def change_dep(self, old_dep, new_dep, is_id, path_to_lib):
cmd = ['-id', new_dep] if is_id else ['-change', old_dep, new_dep]
subprocess.check_call(['install_name_tool'] + cmd + [path_to_lib])
@flush
def fix_dependencies_in_lib(self, path_to_lib):
check_universal(path_to_lib)
self.to_strip.append(path_to_lib)
old_mode = flipwritable(path_to_lib)
for dep, bname, is_id in self.get_local_dependencies(path_to_lib):
ndep = self.FID + '/' + bname
self.change_dep(dep, ndep, is_id, path_to_lib)
ldeps = list(self.get_local_dependencies(path_to_lib))
if ldeps:
print('\nFailed to fix dependencies in', path_to_lib)
print('Remaining local dependencies:', ldeps)
raise SystemExit(1)
if old_mode is not None:
flipwritable(path_to_lib, old_mode)
@flush
def add_python_framework(self):
print('\nAdding Python framework')
src = join(PREFIX + '/python', 'Python.framework')
x = join(self.frameworks_dir, 'Python.framework')
curr = os.path.realpath(join(src, 'Versions', 'Current'))
currd = join(x, 'Versions', basename(curr))
rd = join(currd, 'Resources')
os.makedirs(rd)
shutil.copy2(join(curr, 'Resources', 'Info.plist'), rd)
shutil.copy2(join(curr, 'Python'), currd)
self.set_id(join(currd, 'Python'),
self.FID + '/Python.framework/Versions/%s/Python' % basename(curr))
# The following is needed for codesign in OS X >= 10.9.5
with current_dir(x):
os.symlink(basename(curr), 'Versions/Current')
for y in ('Python', 'Resources'):
os.symlink('Versions/Current/%s' % y, y)
@flush
def add_qt_frameworks(self):
print('\nAdding Qt Frameworks')
# First add FFMPEG
for x in os.listdir(join(PREFIX, 'ffmpeg', 'lib')):
if x.endswith('.dylib') and x.count('.') == 2:
src = join(PREFIX, 'ffmpeg', 'lib', x)
shutil.copy2(src, self.frameworks_dir)
dest = join(self.frameworks_dir, os.path.basename(src))
self.set_id(dest, self.FID + '/' + os.path.basename(src))
self.fix_dependencies_in_lib(dest)
for f in QT_FRAMEWORKS:
self.add_qt_framework(f)
pdir = join(QT_PREFIX, 'plugins')
ddir = join(self.contents_dir, 'PlugIns')
os.mkdir(ddir)
for x in QT_PLUGINS:
shutil.copytree(join(pdir, x), join(ddir, x))
for l in glob.glob(join(ddir, '*/*.dylib')):
self.fix_dependencies_in_lib(l)
x = os.path.relpath(l, ddir)
self.set_id(l, '@executable_path/' + x)
webengine_process = os.path.realpath(join(
self.frameworks_dir, 'QtWebEngineCore.framework/Versions/Current/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess'))
self.fix_dependencies_in_lib(webengine_process)
cdir = dirname(dirname(webengine_process))
dest = join(cdir, 'Frameworks')
os.symlink(os.path.relpath(self.frameworks_dir, cdir), dest)
def add_qt_framework(self, f):
libname = f
f = f + '.framework'
src = join(PREFIX, 'qt', 'lib', f)
ignore = shutil.ignore_patterns('Headers', '*.h', 'Headers/*')
dest = join(self.frameworks_dir, f)
shutil.copytree(src, dest, symlinks=True,
ignore=ignore)
lib = os.path.realpath(join(dest, libname))
rpath = os.path.relpath(lib, self.frameworks_dir)
self.set_id(lib, self.FID + '/' + rpath)
self.fix_dependencies_in_lib(lib)
# The following is needed for codesign in OS X >= 10.9.5
# The presence of the .prl file in the root of the framework causes
# codesign to fail.
with current_dir(dest):
for x in os.listdir('.'):
if x != 'Versions' and not os.path.islink(x):
os.remove(x)
@flush
def create_skeleton(self):
c = join(self.build_dir, 'Contents')
for x in ('Frameworks', 'MacOS', 'Resources'):
os.makedirs(join(c, x))
icons = glob.glob(join(CALIBRE_DIR, 'icons', 'icns', '*.iconset'))
if not icons:
raise SystemExit('Failed to find icns format icons')
for x in icons:
subprocess.check_call([
'iconutil', '-c', 'icns', x, '-o', join(
self.resources_dir, basename(x).partition('.')[0] + '.icns')])
for helpers in (self.helpers_dir,):
os.makedirs(helpers)
cdir = dirname(helpers)
dest = join(cdir, 'Frameworks')
src = self.frameworks_dir
os.symlink(os.path.relpath(src, cdir), dest)
dest = join(cdir, 'Resources')
src = self.resources_dir
os.symlink(os.path.relpath(src, cdir), dest)
pl = dict(
CFBundleDevelopmentRegion='English',
CFBundleDisplayName=APPNAME + ' - utils',
CFBundleName=APPNAME + '-utils',
CFBundleIdentifier='com.calibre-ebook.utils',
LSBackgroundOnly='1',
CFBundleVersion=VERSION,
CFBundleShortVersionString=VERSION,
CFBundlePackageType='APPL',
CFBundleSignature='????',
CFBundleExecutable='pdftohtml',
LSMinimumSystemVersion=MINIMUM_SYSTEM_VERSION,
LSRequiresNativeExecution=True,
NSAppleScriptEnabled=False,
CFBundleIconFile='',
)
with open(join(cdir, 'Info.plist'), 'wb') as p:
plistlib.dump(pl, p)
@flush
def add_calibre_plugins(self):
dest = join(self.frameworks_dir, 'plugins')
os.mkdir(dest)
print('Extracting extension modules from:', self.ext_dir, 'to', dest)
self.ext_map = extract_extension_modules(self.ext_dir, dest)
plugins = glob.glob(dest + '/*.so')
if not plugins:
raise SystemExit('No calibre plugins found in: ' + self.ext_dir)
for f in plugins:
self.fix_dependencies_in_lib(f)
@flush
def create_plist(self):
BOOK_EXTENSIONS = calibre_constants['book_extensions']
env = dict(**ENV)
env['CALIBRE_LAUNCHED_FROM_BUNDLE'] = '1'
docs = [{
'CFBundleTypeName': 'E-book',
'CFBundleTypeExtensions': list(BOOK_EXTENSIONS),
'CFBundleTypeIconFile': 'book.icns',
'CFBundleTypeRole': 'Viewer',
}]
url_handlers = [dict(
CFBundleTypeRole='Viewer',
CFBundleURLIconFile='calibre',
CFBundleURLName='com.calibre-ebook.calibre-url',
CFBundleURLSchemes=['calibre']
)]
pl = dict(
CFBundleDevelopmentRegion='English',
CFBundleDisplayName=APPNAME,
CFBundleName=APPNAME,
CFBundleIdentifier='net.kovidgoyal.calibre',
CFBundleVersion=VERSION,
CFBundleShortVersionString=VERSION,
CFBundlePackageType='APPL',
CFBundleSignature='????',
CFBundleExecutable='calibre',
CFBundleDocumentTypes=docs,
CFBundleURLTypes=url_handlers,
LSMinimumSystemVersion=MINIMUM_SYSTEM_VERSION,
LSRequiresNativeExecution=True,
NSAppleScriptEnabled=False,
NSSupportsAutomaticGraphicsSwitching=True,
NSHumanReadableCopyright=time.strftime('Copyright %Y, Kovid Goyal'),
CFBundleGetInfoString=('calibre, an E-book management '
'application. Visit https://calibre-ebook.com for details.'),
CFBundleIconFile='calibre.icns',
NSHighResolutionCapable=True,
LSApplicationCategoryType='public.app-category.productivity',
LSEnvironment=env
)
with open(join(self.contents_dir, 'Info.plist'), 'wb') as p:
plistlib.dump(pl, p)
@flush
def install_dylib(self, path, set_id=True, dest=None):
dest = dest or self.frameworks_dir
os.makedirs(dest, exist_ok=True)
shutil.copy2(path, dest)
if set_id:
self.set_id(join(dest, basename(path)),
self.FID + '/' + basename(path))
self.fix_dependencies_in_lib(join(dest, basename(path)))
@flush
def add_podofo(self):
print('\nAdding PoDoFo')
pdf = join(PREFIX, 'lib', 'libpodofo.2.dylib')
self.install_dylib(pdf)
@flush
def add_poppler(self):
print('\nAdding poppler')
for x in ('libopenjp2.7.dylib', 'libpoppler.130.dylib',):
self.install_dylib(join(PREFIX, 'lib', x))
for x in ('pdftohtml', 'pdftoppm', 'pdfinfo', 'pdftotext'):
self.install_dylib(
join(PREFIX, 'bin', x), set_id=False, dest=self.helpers_dir)
@flush
def add_imaging_libs(self):
print('\nAdding libjpeg, libpng, libwebp, optipng and mozjpeg')
for x in ('jpeg.8', 'png16.16', 'webp.7', 'webpmux.3', 'webpdemux.2', 'sharpyuv.0'):
self.install_dylib(join(PREFIX, 'lib', 'lib%s.dylib' % x))
for x in 'optipng', 'JxrDecApp', 'cwebp':
self.install_dylib(join(PREFIX, 'bin', x), set_id=False, dest=self.helpers_dir)
for x in ('jpegtran', 'cjpeg'):
self.install_dylib(
join(PREFIX, 'private', 'mozjpeg', 'bin', x), set_id=False, dest=self.helpers_dir)
@flush
def add_fontconfig(self):
print('\nAdding fontconfig')
for x in ('fontconfig.1', 'freetype.6', 'expat.1'):
src = join(PREFIX, 'lib', 'lib' + x + '.dylib')
self.install_dylib(src)
dst = join(self.resources_dir, 'fonts')
if os.path.exists(dst):
shutil.rmtree(dst)
src = join(PREFIX, 'etc', 'fonts')
shutil.copytree(src, dst, symlinks=False)
fc = join(dst, 'fonts.conf')
with open(fc, 'rb') as f:
raw = f.read().decode('utf-8')
raw = raw.replace('<dir>/usr/share/fonts</dir>', '''\
<dir>/Library/Fonts</dir>
<dir>/System/Library/Fonts</dir>
<dir>/usr/X11R6/lib/X11/fonts</dir>
<dir>/usr/share/fonts</dir>
<dir>/var/root/Library/Fonts</dir>
<dir>/usr/share/fonts</dir>
''')
open(fc, 'wb').write(raw.encode('utf-8'))
@flush
def add_misc_libraries(self):
def add_lib(src):
x = os.path.basename(src)
print('\nAdding', x)
shutil.copy2(src, self.frameworks_dir)
dest = join(self.frameworks_dir, x)
self.set_id(dest, self.FID + '/' + x)
self.fix_dependencies_in_lib(dest)
for x in (
'usb-1.0.0', 'mtp.9', 'chm.0', 'sqlite3.0', 'hunspell-1.7.0',
'icudata.73', 'icui18n.73', 'icuio.73', 'icuuc.73', 'hyphen.0', 'uchardet.0',
'stemmer.0', 'xslt.1', 'exslt.0', 'xml2.2', 'z.1', 'unrar', 'lzma.5',
'brotlicommon.1', 'brotlidec.1', 'brotlienc.1', 'zstd.1', 'jbig.2.1', 'tiff.6',
'crypto.3', 'ssl.3', 'iconv.2', # 'ltdl.7'
):
x = 'lib%s.dylib' % x
src = join(PREFIX, 'lib', x)
add_lib(src)
# OpenSSL modules and engines
for x in ('ossl-modules', 'engines-3'):
dest = join(self.frameworks_dir, x)
shutil.copytree(join(PREFIX, 'lib', x), dest)
for dylib in os.listdir(dest):
if dylib.endswith('.dylib'):
dylib = join(dest, dylib)
self.set_id(dylib, self.FID + '/' + x + '/' + os.path.basename(dylib))
self.fix_dependencies_in_lib(dylib)
# Piper TTS
copy_piper_dir(PREFIX, self.frameworks_dir)
@flush
def add_site_packages(self):
print('\nAdding site-packages')
os.makedirs(self.site_packages)
sys_path = json.loads(subprocess.check_output([
PYTHON, '-c', 'import sys, json; json.dump(sys.path, sys.stdout)']))
paths = reversed(tuple(map(abspath, [x for x in sys_path if x.startswith('/') and not x.startswith('/Library/')])))
upaths = []
for x in paths:
if x not in upaths and (x.endswith('.egg') or x.endswith('/site-packages')):
upaths.append(x)
upaths.append(join(CALIBRE_DIR, 'src'))
for x in upaths:
print('\t', x)
tdir = None
try:
if not os.path.isdir(x):
zf = zipfile.ZipFile(x)
tdir = tempfile.mkdtemp()
zf.extractall(tdir)
x = tdir
self.add_modules_from_dir(x)
self.add_packages_from_dir(x)
finally:
if tdir is not None:
shutil.rmtree(tdir)
fix_pycryptodome(self.site_packages)
try:
shutil.rmtree(join(self.site_packages, 'calibre', 'plugins'))
except OSError as err:
if err.errno != errno.ENOENT:
raise
sp = join(self.resources_dir, 'Python', 'site-packages')
self.remove_bytecode(sp)
@flush
def add_modules_from_dir(self, src):
for x in glob.glob(join(src, '*.py')) + glob.glob(join(src, '*.so')):
dest = join(self.site_packages, os.path.basename(x))
shutil.copy2(x, dest)
if x.endswith('.so'):
self.fix_dependencies_in_lib(dest)
@flush
def add_packages_from_dir(self, src):
for x in os.listdir(src):
x = join(src, x)
if os.path.isdir(x) and is_package_dir(x):
if self.filter_package(basename(x)):
continue
self.add_package_dir(x)
@flush
def add_package_dir(self, x, dest=None):
is_kakasi = 'pykakasi' in x
allowed_exts = ('', '.py', '.so')
if is_kakasi:
allowed_exts += ('.db',)
def ignore(root, files):
ans = []
for y in files:
ext = os.path.splitext(y)[1]
if ext not in allowed_exts or (not ext and not os.path.isdir(join(root, y))):
ans.append(y)
return ans
if dest is None:
dest = self.site_packages
dest = join(dest, basename(x))
shutil.copytree(x, dest, symlinks=True, ignore=ignore)
self.postprocess_package(x, dest)
for x in os.walk(dest):
for f in x[-1]:
if f.endswith('.so'):
f = join(x[0], f)
self.fix_dependencies_in_lib(f)
@flush
def filter_package(self, name):
return name in ('Cython', 'modulegraph', 'macholib', 'py2app',
'bdist_mpkg', 'altgraph')
@flush
def postprocess_package(self, src_path, dest_path):
pass
@flush
def add_stdlib(self):
print('\nAdding python stdlib')
src = PREFIX + '/python/Python.framework/Versions/Current/lib/python'
src += py_ver
dest = join(self.resources_dir, 'Python', 'lib', 'python')
dest += py_ver
os.makedirs(dest)
for x in os.listdir(src):
if x in ('site-packages', 'config', 'test', 'lib2to3', 'lib-tk',
'lib-old', 'idlelib', 'plat-mac', 'plat-darwin', 'site.py'):
continue
x = join(src, x)
if os.path.isdir(x):
self.add_package_dir(x, dest)
elif os.path.splitext(x)[1] in ('.so', '.py'):
shutil.copy2(x, dest)
dest2 = join(dest, basename(x))
if dest2.endswith('.so'):
self.fix_dependencies_in_lib(dest2)
target = join(self.resources_dir, 'Python', 'lib')
self.remove_bytecode(target)
for path in walk(target):
if path.endswith('.so'):
self.fix_dependencies_in_lib(path)
@flush
def remove_bytecode(self, dest):
for x in os.walk(dest):
root = x[0]
for f in x[-1]:
if os.path.splitext(f) in ('.pyc', '.pyo'):
os.remove(join(root, f))
@flush
def compile_py_modules(self):
print('\nCompiling Python modules')
base = join(self.resources_dir, 'Python')
pydir = join(base, f'lib/python{py_ver}')
src = join(pydir, 'lib-dynload')
dest = join(self.frameworks_dir, 'plugins')
print('Extracting extension modules from:', src, 'to', dest)
self.ext_map.update(extract_extension_modules(src, dest))
os.rmdir(src)
src = join(base, 'site-packages')
print('Extracting extension modules from:', src, 'to', dest)
self.ext_map.update(extract_extension_modules(src, dest))
for x in os.listdir(src):
os.rename(join(src, x), join(pydir, x))
os.rmdir(src)
py_compile(pydir)
freeze_python(
pydir, dest, self.inc_dir, self.ext_map,
develop_mode_env_var='CALIBRE_DEVELOP_FROM',
path_to_user_env_vars='~/Library/Preferences/calibre/macos-env.txt'
)
shutil.rmtree(pydir)
def create_app_clone(self, name, specialise_plist, remove_doc_types=False, base_dir=None):
print('\nCreating ' + name)
base_dir = base_dir or self.contents_dir
cc_dir = join(base_dir, name, 'Contents')
exe_dir = join(cc_dir, 'MacOS')
rel_path = os.path.relpath(join(self.contents_dir, 'MacOS'), exe_dir)
os.makedirs(exe_dir)
for x in os.listdir(self.contents_dir):
if x.endswith('.app'):
continue
if x == 'Info.plist':
with open(join(self.contents_dir, x), 'rb') as r:
plist = plistlib.load(r)
specialise_plist(plist)
if remove_doc_types:
plist.pop('CFBundleDocumentTypes')
exe = plist['CFBundleExecutable']
# We cannot symlink the bundle executable as if we do,
# codesigning fails
plist['CFBundleExecutable'] = exe + '-placeholder-for-codesigning'
nexe = join(exe_dir, plist['CFBundleExecutable'])
base = os.path.dirname(abspath(__file__))
cmd = gcc + ARCH_FLAGS + CFLAGS + [
'-Wall', '-Werror', '-DEXE_NAME="%s"' % exe, '-DREL_PATH="%s"' % rel_path,
join(base, 'placeholder.c'), '-o', nexe, '-headerpad_max_install_names'
]
subprocess.check_call(cmd)
with open(join(cc_dir, x), 'wb') as p:
plistlib.dump(plist, p)
elif x == 'MacOS':
for item in os.listdir(join(self.contents_dir, 'MacOS')):
src = join(self.contents_dir, x, item)
os.symlink(os.path.relpath(src, exe_dir), join(exe_dir, item))
else:
src = join(self.contents_dir, x)
os.symlink(os.path.relpath(src, cc_dir), join(cc_dir, x))
@flush
def create_gui_apps(self):
def get_data(cmd):
return json.loads(subprocess.check_output([join(self.contents_dir, 'MacOS', 'calibre-debug'), '-c', cmd]))
data = get_data(
'from calibre.customize.ui import all_input_formats; import sys, json; from calibre.ebooks.oeb.polish.main import SUPPORTED;'
'sys.stdout.write(json.dumps({"i": tuple(all_input_formats()), "e": tuple(SUPPORTED)}))'
)
input_formats = sorted(set(data['i']))
edit_formats = sorted(set(data['e']))
def specialise_plist(launcher, formats, plist):
plist['CFBundleDisplayName'] = plist['CFBundleName'] = {
'ebook-viewer': 'E-book Viewer', 'ebook-edit': 'Edit Book',
}[launcher]
plist['CFBundleExecutable'] = launcher
plist['CFBundleIdentifier'] = 'com.calibre-ebook.' + launcher
plist['CFBundleIconFile'] = launcher + '.icns'
e = plist['CFBundleDocumentTypes'][0]
e['CFBundleTypeExtensions'] = [x.lower() for x in formats]
def headless_plist(plist):
plist['CFBundleDisplayName'] = 'calibre worker process'
plist['CFBundleExecutable'] = 'calibre-parallel'
plist['CFBundleIdentifier'] = 'com.calibre-ebook.calibre-parallel'
plist['LSBackgroundOnly'] = '1'
plist.pop('CFBundleDocumentTypes')
self.create_app_clone('ebook-viewer.app', partial(specialise_plist, 'ebook-viewer', input_formats))
self.create_app_clone('ebook-edit.app', partial(specialise_plist, 'ebook-edit', edit_formats),
base_dir=join(self.contents_dir, 'ebook-viewer.app', 'Contents'))
self.create_app_clone('headless.app', headless_plist,
base_dir=join(self.contents_dir, 'ebook-viewer.app', 'Contents', 'ebook-edit.app', 'Contents'))
# We need to move the webengine resources into the deepest sub-app
# because the sandbox gets set to the nearest enclosing app which
# means that WebEngine will fail to access its resources when running
# in the sub-apps unless they are present inside the sub app bundle
# somewhere
base_dest = join(self.contents_dir, 'ebook-viewer.app', 'Contents', 'ebook-edit.app', 'Contents', 'headless.app', 'Contents', 'SharedSupport')
os.mkdir(base_dest)
base_src = os.path.realpath(join(self.frameworks_dir, 'QtWebEngineCore.framework/Resources'))
items = [join(base_src, 'qtwebengine_locales')] + glob.glob(join(base_src, '*.pak')) + glob.glob(join(base_src, '*.dat'))
for src in items:
dest = join(base_dest, os.path.basename(src))
os.rename(src, dest)
os.symlink(os.path.relpath(dest, base_src), src)
@flush
def copy_site(self):
base = os.path.dirname(abspath(__file__))
shutil.copy2(join(base, 'site.py'), join(self.resources_dir, 'Python',
'lib', 'python' + py_ver))
@flush
def makedmg(self, d, volname):
''' Copy a directory d into a dmg named volname '''
print('\nSigning...')
sys.stdout.flush()
destdir = OUTPUT_DIR
try:
shutil.rmtree(destdir)
except EnvironmentError as err:
if err.errno != errno.ENOENT:
raise
os.mkdir(destdir)
dmg = join(destdir, volname + '.dmg')
if os.path.exists(dmg):
os.unlink(dmg)
tdir = tempfile.mkdtemp()
appdir = join(tdir, os.path.basename(d))
shutil.copytree(d, appdir, symlinks=True)
if self.sign_installers or self.notarize:
with timeit() as times:
sign_app(appdir, self.notarize)
print('Signing completed in %d minutes %d seconds' % tuple(times))
os.symlink('/Applications', join(tdir, 'Applications'))
size_in_mb = int(subprocess.check_output(['du', '-s', '-k', tdir]).decode('utf-8').split()[0]) / 1024.
# ULMO (10.15+) gives the best compression, better than ULFO and better+faster than UDBZ
cmd = ['/usr/bin/hdiutil', 'create', '-srcfolder', tdir, '-volname', volname, '-format', 'ULMO']
if 190 < size_in_mb < 250:
# We need -size 255m because of a bug in hdiutil. When the size of
# srcfolder is close to 200MB hdiutil fails with
# diskimages-helper: resize request is above maximum size allowed.
cmd += ['-size', '255m']
print('\nCreating dmg...')
with timeit() as times:
subprocess.check_call(cmd + [dmg])
print('dmg created in %d minutes and %d seconds' % tuple(times))
shutil.rmtree(tdir)
size = os.stat(dmg).st_size / (1024 * 1024.)
print('\nInstaller size: %.2fMB\n' % size)
return dmg
def main(args, ext_dir, test_runner):
build_dir = abspath(join(mkdtemp('frozen-'), APPNAME + '.app'))
inc_dir = abspath(mkdtemp('include'))
if args.skip_tests:
def test_runner(*a):
return None
Freeze(build_dir, ext_dir, inc_dir, test_runner, dont_strip=args.dont_strip, sign_installers=args.sign_installers, notarize=args.notarize)
if __name__ == '__main__':
args = globals()['args']
ext_dir = globals()['ext_dir']
run_tests = iv['run_tests']
main(args, ext_dir, run_tests)