File: bundle.py

package info (click to toggle)
naev 0.8.2-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, forky, sid, trixie
  • size: 386,084 kB
  • sloc: ansic: 93,149; xml: 87,292; python: 2,347; sh: 904; makefile: 654; lisp: 162; awk: 4
file content (151 lines) | stat: -rwxr-xr-x 6,151 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
#!/usr/bin/env python3

# MACOS PACKAGING SCRIPT FOR NAEV
# This script should be run after compiling Naev.

# This script assumes a dependency must be bundled (with priority over any installations the end user might have) if either
# | it's in a known Homebrew or osxcross path (LOCAL_LIB_ROOTS below)
# | it's relative to the rpath (Meson subproject / embedded lib)

import argparse
import os
import shutil
import subprocess


LOCAL_LIB_ROOTS = ('/opt/local', '/usr/lib/osxcross', '/usr/local')


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-d", "--debug", help="Trace the script's actions.", action="store_true")
    parser.add_argument("-n", "--nightly", help="Set this for nightly builds.", action="store_true")
    parser.add_argument("-s", "--sourceroot", help="Set this to the repo location.", default=os.path.abspath('.'))
    parser.add_argument("-b", "--buildroot", help="Set this to the build location.", default=os.path.abspath('build'))
    parser.add_argument("-o", "--buildoutput", help="Set location for dist output.", default=os.path.abspath('dist'))
    args = parser.parse_args()

    trace.enabled = args.debug
    app_path = f'{args.buildoutput}/out/Naev.app'

    #Clean previous build.
    trace(shutil.rmtree, app_path, ignore_errors=True)

    # Build basic structure.
    for subdir in 'MacOS', 'Resources', 'Frameworks':
        trace(os.makedirs, f'{app_path}/Contents/{subdir}')
    trace(shutil.copy, f'{args.buildroot}/Info.plist', f'{app_path}/Contents')
    trace(shutil.copy, f'{args.sourceroot}/extras/macos/naev.icns', f'{app_path}/Contents/Resources')

    # Gather Naev and dependencies.
    trace(copy_with_deps, f'{args.buildroot}/naev', app_path, dest='Contents/MacOS')

    # Strip headers, especially from the SDL2 framework.
    trace(subprocess.check_call, ['find', app_path, '-name', 'Headers', '-prune', '-exec', 'rm', '-r', '{}', '+'])

    # Install data.
    trace(shutil.copytree, f'{args.sourceroot}/dat', f'{app_path}/Contents/Resources/dat')
    trace(subprocess.check_call, [f'{args.sourceroot}/utils/package-po.sh',
        '-b', args.buildroot, '-o', f'{app_path}/Contents/Resources'])

    print(f'Successfully created {app_path}')


def trace(f, *args, **kwargs):
    """ Run the given function on the given arguments. In debug mode, print the function call.
        (Indent if we trace while running a traced function.) """

    trace.depth += 1
    try:
        if trace.enabled:
            # HACK: when tracing a function call, use unittest.mock to construct a pretty-printable version.
            # HACK: when tracing subprocess shell commands, format them because it's unreadable otherwise.
            if f.__module__ == 'subprocess' and len(args) == 1:
                from subprocess import list2cmdline
                print(f'[{trace.depth}]', '$', list2cmdline(args[0]))
            else:
                from unittest import mock
                call = getattr(mock.call, f.__name__)(*args, **kwargs)
                pretty = str(call).replace('call.', '', 1)
                print(f'[{trace.depth}]', pretty)
        return f(*args, **kwargs)
    finally:
        trace.depth -= 1
trace.depth = 0
trace.enabled = False


def copy_with_deps(bin_src, app_path, dest='Contents/Frameworks', exe_rpaths=None):
    loader_path = os.path.dirname(bin_src)
    bin_name = os.path.basename(bin_src)
    bin_dst = os.path.join(app_path, dest, bin_name)

    trace(shutil.copy, bin_src, bin_dst)

    dylibs, rpaths = otool_results(bin_src)
    # Now we have a "dylibs" list, where some entries may be "@rpath/" relative,
    # and we have a "rpaths" list, where some entries may be "@loader_path/" relative.
    # The "rpaths" list only matters when it comes from the Naev executable, so record that result
    if exe_rpaths is None:
        exe_rpaths = list(rpaths)

    change_cmd = [host_program('install_name_tool')]
    if bin_dst.endswith('/naev'):
        change_cmd.extend(['-add_rpath', '@executable_path/../Frameworks'])
    if bin_dst.endswith('.dylib'):
        change_cmd.extend(['-id', f'@rpath/{bin_name}'])

    for dylib in dylibs:
        dylib_name = os.path.basename(dylib)
        dylib_path = search_and_pop_rpath(dylib, exe_rpaths, loader_path)
        if dylib_path:
            if not os.path.exists(os.path.join(app_path, dest, dylib_name)):
                trace(copy_with_deps, dylib_path, app_path)
            change_cmd.extend(['-change', dylib, f'@rpath/{dylib_name}'])

    for rpath in set(rpaths).difference(exe_rpaths):
        change_cmd.extend(['-delete_rpath', rpath])

    change_cmd.append(bin_dst)
    trace(subprocess.check_call, change_cmd)


def otool_results(bin_src):
    operands = {'LC_LOAD_DYLIB': [], 'LC_RPATH': []}
    operand_list = unwanted = []
    for line in subprocess.check_output([host_program('otool'), '-l', bin_src]).decode().splitlines():
        line = line.lstrip()
        if line.startswith('cmd '):
            operand_list = operands.get(line[4:], unwanted)
        elif line.startswith(('name ', 'path ')):
            operand_list.append(line[5:].rsplit(' ', 2)[0]) # "name /some/path (offset 42)" -> "/some/path"
    return operands['LC_LOAD_DYLIB'], operands['LC_RPATH']


def search_and_pop_rpath(dylib, rpaths, loader_path):
    dylib_path = None
    if dylib.startswith(LOCAL_LIB_ROOTS):
        dylib_path = dylib
        lib_dir = os.path.realpath(os.path.dirname(dylib))
        for rpath in list(rpaths):
            if os.path.realpath(rpath) == lib_dir:
                rpaths.remove(rpath)
    elif dylib.startswith('@rpath/'):
        lib_base = dylib.replace('@rpath/', '', 1)
        for rpath in list(rpaths):
            trial_path = os.path.join(rpath.replace('@loader_path', loader_path), lib_base)
            if os.path.exists(trial_path):
                dylib_path = dylib_path or trial_path  # We should bundle the first matching lib.
                rpaths.remove(rpath)
    return dylib_path


def host_program(name):
    try:
        return os.environ['HOST'] + '-' + name
    except KeyError:
        return name


if __name__ == '__main__':
    main()