mirror of
https://gitlab.com/openlp/packaging.git
synced 2024-09-01 03:33:42 +00:00
425 lines
17 KiB
Python
425 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
|
|
|
|
##########################################################################
|
|
# OpenLP - Open Source Lyrics Projection #
|
|
# ---------------------------------------------------------------------- #
|
|
# Copyright (c) 2008-2019 OpenLP Developers #
|
|
# ---------------------------------------------------------------------- #
|
|
# This program is free software: you can redistribute it and/or modify #
|
|
# it under the terms of the GNU General Public License as published by #
|
|
# the Free Software Foundation, either version 3 of the License, or #
|
|
# (at your option) any later version. #
|
|
# #
|
|
# This program is distributed in the hope that it will be useful, #
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
|
# GNU General Public License for more details. #
|
|
# #
|
|
# You should have received a copy of the GNU General Public License #
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
|
##########################################################################
|
|
|
|
"""
|
|
Mac OS X Build Script
|
|
--------------------
|
|
|
|
This script is used to build the Mac OS X app bundle and pack it into dmg file.
|
|
For this script to work out of the box, it depends on a number of things:
|
|
|
|
Python 3.7
|
|
|
|
PyQt5
|
|
You should already have this installed, OpenLP doesn't work without it. The
|
|
version the script expects is the packaged one available from River Bank
|
|
Computing.
|
|
|
|
PyEnchant
|
|
This script expects the precompiled, installable version of PyEnchant to be
|
|
installed. You can find this on the PyEnchant site.
|
|
|
|
Sphinx
|
|
This is used to build the documentation. The documentation trunk must be at
|
|
the same directory level as OpenLP trunk and named "documentation".
|
|
|
|
PyInstaller
|
|
PyInstaller can be installed with pip
|
|
|
|
Git
|
|
You need the command line "git" client installed.
|
|
|
|
OpenLP
|
|
A checkout of the latest code, in a branch directory, which is in a Bazaar
|
|
shared repository directory. This means your code should be in a directory
|
|
structure like this: "openlp\branch-name".
|
|
|
|
macosx-builder.py
|
|
This script, of course. It should be in the "osx-package" directory
|
|
at the same level as OpenLP trunk.
|
|
|
|
Mako
|
|
Mako Templates for Python. This package is required for building the
|
|
remote plugin.
|
|
|
|
Alembic
|
|
Required for upgrading the databases used in OpenLP.
|
|
|
|
PyMuPDF
|
|
Required for PDF support in OpenLP. Install using pip.
|
|
|
|
MachOLib
|
|
Python library to analyze and edit Mach-O headers, the executable format
|
|
used by Mac OS X. Used to relink the mudraw binary from MuPDF to the bundled
|
|
libraries. Install using macports or pip.
|
|
|
|
config.ini.default
|
|
The configuration file contains settings of the version string to include
|
|
in the bundle as well as directory and file settings for different
|
|
purposes (e.g. PyInstaller location or installer background image)
|
|
|
|
To install everything you should install latest python 3.7 from python.org. It
|
|
is recommended to create virtual environment. You can install all dependencies
|
|
like this:
|
|
|
|
$ python -m pip install sqlalchemy alembic appdirs chardet beautifulsoup4 \
|
|
lxml Mako mysql-connector-python pytest mock psycopg2-binary \
|
|
websockets asyncio waitress six webob requests QtAwesome PyQt5 \
|
|
PyQtWebEngine pymediainfo PyMuPDF==1.16.7 QDarkStyle python-vlc \
|
|
Pyro4 zeroconf flask-cors pytest-qt pyenchant pysword pyobjc-core \
|
|
pyobjc-framework-Cocoa dmgbuild sphinx PyInstaller
|
|
|
|
"""
|
|
|
|
import glob
|
|
import os
|
|
import distutils
|
|
from pathlib import Path
|
|
from shutil import copy, copytree, move, rmtree
|
|
|
|
from macholib.MachO import MachO
|
|
from macholib.util import in_system_path
|
|
|
|
from builder import Builder
|
|
|
|
|
|
class MacOSXBuilder(Builder):
|
|
"""
|
|
The :class:`MacosxBuilder` class encapsulates everything that is needed
|
|
to build a Mac OS X .dmg file.
|
|
"""
|
|
def _get_directory_size(self, directory):
|
|
"""
|
|
Return directory size - size of everything in the dir.
|
|
"""
|
|
dir_size = 0
|
|
for (path, dirs, files) in os.walk(directory):
|
|
for file in files:
|
|
filename = os.path.join(path, file)
|
|
dir_size += os.path.getsize(filename)
|
|
return dir_size
|
|
|
|
def _create_symlink(self, folder):
|
|
"""
|
|
Create the appropriate symlink in the MacOS folder pointing to the Resources folder.
|
|
"""
|
|
sibling = Path(str(folder).replace('MacOS', ''))
|
|
|
|
# PyQt5/Qt/qml/QtQml/Models.2
|
|
root = str(sibling).partition('Contents')[2].lstrip('/')
|
|
# ../../../../
|
|
backward = '../' * len(root.split('/'))
|
|
# ../../../../Resources/PyQt5/Qt/qml/QtQml/Models.2
|
|
good_path = f'{backward}Resources/{root}'
|
|
|
|
folder.symlink_to(good_path)
|
|
|
|
def _fix_qt_dll(self, dll_file):
|
|
"""
|
|
Fix the DLL lookup paths to use relative ones for Qt dependencies.
|
|
Inspiration: PyInstaller/depend/dylib.py:mac_set_relative_dylib_deps()
|
|
Currently one header is pointing to (we are in the Resources folder):
|
|
@loader_path/../../../../QtCore (it is referencing to the old MacOS folder)
|
|
It will be converted to:
|
|
@loader_path/../../../../../../MacOS/QtCore
|
|
"""
|
|
|
|
def match_func(pth):
|
|
"""
|
|
Callback function for MachO.rewriteLoadCommands() that is
|
|
called on every lookup path setted in the DLL headers.
|
|
By returning None for system libraries, it changes nothing.
|
|
Else we return a relative path pointing to the good file
|
|
in the MacOS folder.
|
|
"""
|
|
basename = os.path.basename(pth)
|
|
if not basename.startswith('Qt'):
|
|
return None
|
|
return f'@loader_path{good_path}/{basename}'
|
|
|
|
# Skip it if it's not a dylib file
|
|
if dll_file.suffix != '.dylib':
|
|
return
|
|
|
|
# Resources/PyQt5/Qt/qml/QtQuick/Controls.2/Fusion
|
|
root = str(dll_file.parent).partition('Contents')[2][1:]
|
|
# /../../../../../../..
|
|
backward = '/..' * len(root.split('/'))
|
|
# /../../../../../../../MacOS
|
|
good_path = f'{backward}/MacOS'
|
|
|
|
# Rewrite Mach headers with corrected @loader_path
|
|
dll = MachO(dll_file)
|
|
dll.rewriteLoadCommands(match_func)
|
|
with open(dll.filename, 'rb+') as f:
|
|
for header in dll.headers:
|
|
f.seek(0)
|
|
dll.write(f)
|
|
f.seek(0, 2)
|
|
f.flush()
|
|
|
|
def _find_problematic_qt_folders(self, folder):
|
|
"""
|
|
Recursively yields problematic folders (containing a dot in their name).
|
|
"""
|
|
for path in folder.iterdir():
|
|
if not path.is_dir() or path.is_symlink():
|
|
# Skip simlinks as they are allowed (even with a dot)
|
|
continue
|
|
if path.name == 'qml':
|
|
yield path
|
|
else:
|
|
yield from self._find_problematic_qt_folders(path)
|
|
|
|
def _move_contents_to_resources(self, folder):
|
|
"""
|
|
Recursively move any non symlink file from a problematic folder to the sibling one in Resources.
|
|
"""
|
|
for path in folder.iterdir():
|
|
if path.is_symlink():
|
|
continue
|
|
if path.is_dir():
|
|
yield from self._move_contents_to_resources(path)
|
|
else:
|
|
sibling = Path(str(path).replace('MacOS', 'Resources'))
|
|
sibling.parent.mkdir(parents=True, exist_ok=True)
|
|
move(path, sibling)
|
|
yield sibling
|
|
|
|
def _fix_qt_paths(self):
|
|
"""
|
|
Fix the Qt paths
|
|
"""
|
|
app_path = Path(self.dist_app_path) / 'Contents' / 'MacOS'
|
|
for folder in self._find_problematic_qt_folders(app_path):
|
|
for problematic_file in self._move_contents_to_resources(folder):
|
|
self._fix_qt_dll(problematic_file)
|
|
rmtree(folder)
|
|
self._create_symlink(folder)
|
|
|
|
def _relink_binary(self, bin_name):
|
|
"""
|
|
Relink bundled libraries
|
|
"""
|
|
self._print('Linking {bin_name} with bundled libraries...'.format(bin_name=bin_name))
|
|
libname = os.path.join(self.dist_path, bin_name)
|
|
distname = os.path.relpath(self.dist_path, libname)
|
|
self._print_verbose('... {bin_name} path {path}'.format(bin_name=bin_name, path=libname))
|
|
|
|
# Determine how many directories up is the directory with shared
|
|
# dynamic libraries. '../'
|
|
# E.g. ./qt4_plugins/images/ -> ./../../
|
|
parent_dir = ''
|
|
# Check if distname is not only base filename.
|
|
if os.path.dirname(distname):
|
|
parent_level = len(os.path.dirname(distname).split(os.sep))
|
|
parent_dir = parent_level * (os.pardir + os.sep)
|
|
|
|
def match_func(pth):
|
|
"""
|
|
For system libraries leave path unchanged.
|
|
"""
|
|
# Match non system dynamic libraries.
|
|
if not in_system_path(pth):
|
|
# Use relative path to dependend dynamic libraries bases on
|
|
# location of the executable.
|
|
pth = os.path.join('@loader_path', parent_dir, os.path.basename(pth))
|
|
self._print_verbose('... %s', pth)
|
|
return pth
|
|
|
|
# Rewrite mach headers with @loader_path.
|
|
dll = MachO(libname)
|
|
dll.rewriteLoadCommands(match_func)
|
|
|
|
# Write changes into file.
|
|
# Write code is based on macholib example.
|
|
try:
|
|
self._print_verbose('... writing new library paths')
|
|
with open(dll.filename, 'rb+') as dll_file:
|
|
for header in dll.headers:
|
|
dll_file.seek(0)
|
|
dll.write(dll_file)
|
|
dll_file.seek(0, 2)
|
|
except Exception:
|
|
pass
|
|
|
|
def _install_pyro4(self):
|
|
"""
|
|
Install Pyro4 into the vendor directory
|
|
"""
|
|
self._print('Installing Pyro4 for LibreOffice')
|
|
target = os.path.join(self.dist_path, 'plugins', 'presentations', 'lib', 'vendor')
|
|
argv = ['pip', 'install', 'Pyro4', '-t', target, '--disable-pip-version-check', '--no-compile']
|
|
self._run_module('pip', argv, 'Error installing pyro4 with pip', run_name='__main__')
|
|
egg_info_glob = glob.glob(os.path.join(target, '*.egg-info'))
|
|
egg_info_glob.extend(glob.glob(os.path.join(target, '*.dist-info')))
|
|
self._print_verbose('... glob: {}'.format(egg_info_glob))
|
|
for path in egg_info_glob:
|
|
rmtree(path, True)
|
|
|
|
def _copy_vlc_files(self):
|
|
"""
|
|
Copy the VLC files into the app bundle
|
|
"""
|
|
vlc_path = '/Applications/VLC.app/Contents/MacOS/'
|
|
vlc_dest = os.path.join(self.dist_path, 'vlc')
|
|
if not os.path.exists(vlc_dest):
|
|
os.makedirs(vlc_dest)
|
|
for fname in ['libvlc.dylib', 'libvlccore.dylib']:
|
|
self._print_verbose('... {}'.format(fname))
|
|
copy(os.path.join(vlc_path, 'lib', fname), os.path.join(vlc_dest, fname))
|
|
self._relink_binary(os.path.join('vlc', fname))
|
|
if os.path.exists(os.path.join(vlc_dest, 'plugins')):
|
|
rmtree(os.path.join(vlc_dest, 'plugins'))
|
|
self._print_verbose('... copying VLC plugins')
|
|
copytree(os.path.join(vlc_path, 'plugins'), os.path.join(vlc_dest, 'plugins'))
|
|
|
|
def _copy_bundle_files(self):
|
|
"""
|
|
Copy Info.plist and OpenLP.icns to app bundle.
|
|
"""
|
|
self._print_verbose('... OpenLP.icns')
|
|
try:
|
|
os.makedirs(os.path.join(self.dist_app_path, 'Contents', 'Resources'))
|
|
except FileExistsError:
|
|
pass
|
|
copy(self.icon_path, os.path.join(self.dist_app_path, 'Contents', 'Resources',
|
|
os.path.basename(self.icon_path)))
|
|
self._print_verbose('... Info.plist')
|
|
# Add OpenLP version to Info.plist and add it to app bundle.
|
|
with open(os.path.join(self.dist_app_path, 'Contents', os.path.basename(self.bundle_info_path)), 'w') as fw, \
|
|
open(self.bundle_info_path, 'r') as fr:
|
|
text = fr.read()
|
|
text = text % {'openlp_version': self.version}
|
|
fw.write(text)
|
|
|
|
def _copy_macosx_files(self):
|
|
"""
|
|
Copy all the OSX-specific files.
|
|
"""
|
|
self._print_verbose('... LICENSE.txt')
|
|
copy(self.license_path, os.path.join(self.dist_path, 'LICENSE.txt'))
|
|
|
|
def _code_sign(self):
|
|
certificate = self.config.get('codesigning', 'certificate')
|
|
self._print('Checking for certificate...')
|
|
if not certificate:
|
|
self._print('Certificate not set, skipping code signing!')
|
|
return
|
|
self._run_command(['security', 'find-certificate', '-c', certificate],
|
|
'Could not find certificate "{certificate}" in keychain, '.format(certificate=certificate) +
|
|
'codesigning will not work without a certificate')
|
|
self._print('Codesigning app...')
|
|
self._run_command(['codesign', '--deep', '-s', certificate, self.dist_app_path], 'Error running codesign')
|
|
|
|
def _create_dmg(self):
|
|
"""
|
|
Create .dmg file.
|
|
"""
|
|
self._print('Creating dmg file...')
|
|
dmg_name = 'OpenLP-{version}.dmg'.format(version=self.version)
|
|
dmg_title = 'OpenLP {version}'.format(version=self.version)
|
|
|
|
self.dmg_file = os.path.join(self.work_path, 'dist', dmg_name)
|
|
# Remove dmg if it exists.
|
|
if os.path.exists(self.dmg_file):
|
|
os.remove(self.dmg_file)
|
|
# Get size of the directory in bytes, convert to MB, and add padding
|
|
size = self._get_directory_size(self.dist_app_path)
|
|
size = size / (1000 * 1000)
|
|
size += 10
|
|
|
|
os.chdir(os.path.dirname(self.dmg_settings_path))
|
|
dmgbuild_exe = os.path.join(distutils.sys.exec_prefix, 'bin', 'dmgbuild')
|
|
argv = [dmgbuild_exe, '-s', self.dmg_settings_path, '-D', 'size={size}M'.format(size=size),
|
|
'-D', 'icon={icon_path}'.format(icon_path=self.icon_path),
|
|
'-D', 'app={dist_app_path}'.format(dist_app_path=self.dist_app_path), dmg_title, self.dmg_file]
|
|
self._run_path(dmgbuild_exe, argv, 'Error running dmgbuild')
|
|
# Dmg done.
|
|
self._print('Finished creating dmg file, resulting file: %s' % self.dmg_file)
|
|
|
|
def get_platform(self):
|
|
"""
|
|
Return the plaform we're building for
|
|
"""
|
|
return 'Mac OS X'
|
|
|
|
def get_sphinx_build(self):
|
|
"""
|
|
The type of build Sphinx should be doing
|
|
"""
|
|
return 'applehelp'
|
|
|
|
def get_qt_translations_path(self):
|
|
"""
|
|
Return the path to Qt translation files on macOS
|
|
"""
|
|
from PyQt5.QtCore import QCoreApplication
|
|
qt_library_path = QCoreApplication.libraryPaths()[0]
|
|
return os.path.join(os.path.dirname(qt_library_path), 'translations')
|
|
|
|
def setup_extra(self):
|
|
"""
|
|
Extra setup to run
|
|
"""
|
|
if hasattr(self, 'mutool_exe'):
|
|
self.mutool_lib = os.path.abspath(
|
|
os.path.join(os.path.dirname(self.mutool_exe), '..', 'lib', 'libjbig2dec.0.dylib'))
|
|
self.dist_app_path = os.path.join(self.work_path, 'dist', 'OpenLP.app')
|
|
self.dist_path = os.path.join(self.work_path, 'dist', 'OpenLP.app', 'Contents', 'MacOS')
|
|
|
|
def copy_extra_files(self):
|
|
"""
|
|
Copy any extra files which are particular to a platform
|
|
"""
|
|
self._print('Copying extra files for macOS...')
|
|
# Exclude VLC for now, at least
|
|
# self._copy_vlc_files()
|
|
self._copy_bundle_files()
|
|
self._copy_macosx_files()
|
|
self._install_pyro4()
|
|
|
|
def after_run_sphinx(self):
|
|
"""
|
|
Run Sphinx to build an HTML Help project.
|
|
"""
|
|
self._print('Copying help file...')
|
|
source = os.path.join(self.manual_build_path, 'applehelp')
|
|
files = os.listdir(source)
|
|
for filename in files:
|
|
if filename.endswith('.help'):
|
|
self._print_verbose('... %s', filename)
|
|
copytree(os.path.join(source, filename),
|
|
os.path.join(self.dist_app_path, 'Contents', 'Resources', filename))
|
|
|
|
def build_package(self):
|
|
"""
|
|
Build the actual DMG
|
|
"""
|
|
self._fix_qt_paths()
|
|
self._code_sign()
|
|
self._create_dmg()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
MacOSXBuilder().main()
|