-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
/
osx.py
720 lines (637 loc) · 40.4 KB
/
osx.py
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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
#-----------------------------------------------------------------------------
# Copyright (c) 2005-2023, PyInstaller Development Team.
#
# Distributed under the terms of the GNU General Public License (version 2
# or later) with exception for distributing the bootloader.
#
# The full license is in the file COPYING.txt, distributed with this software.
#
# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
#-----------------------------------------------------------------------------
import os
import pathlib
import plistlib
import shutil
import subprocess
from PyInstaller.building.api import COLLECT, EXE
from PyInstaller.building.datastruct import Target, logger, normalize_toc
from PyInstaller.building.utils import _check_path_overlap, _rmtree, process_collected_binary
from PyInstaller.compat import is_darwin, strict_collect_mode
from PyInstaller.building.icon import normalize_icon_type
import PyInstaller.utils.misc as miscutils
if is_darwin:
import PyInstaller.utils.osx as osxutils
# Character sequence used to replace dot (`.`) in names of directories that are created in `Contents/MacOS` or
# `Contents/Frameworks`, where only .framework bundle directories are allowed to have dot in name.
DOT_REPLACEMENT = '__dot__'
class BUNDLE(Target):
def __init__(self, *args, **kwargs):
from PyInstaller.config import CONF
# BUNDLE only has a sense under Mac OS, it's a noop on other platforms
if not is_darwin:
return
# Get a path to a .icns icon for the app bundle.
self.icon = kwargs.get('icon')
if not self.icon:
# --icon not specified; use the default in the pyinstaller folder
self.icon = os.path.join(
os.path.dirname(os.path.dirname(__file__)), 'bootloader', 'images', 'icon-windowed.icns'
)
else:
# User gave an --icon=path. If it is relative, make it relative to the spec file location.
if not os.path.isabs(self.icon):
self.icon = os.path.join(CONF['specpath'], self.icon)
super().__init__()
# .app bundle is created in DISTPATH.
self.name = kwargs.get('name', None)
base_name = os.path.basename(self.name)
self.name = os.path.join(CONF['distpath'], base_name)
self.appname = os.path.splitext(base_name)[0]
self.version = kwargs.get("version", "0.0.0")
self.toc = []
self.strip = False
self.upx = False
self.console = True
self.target_arch = None
self.codesign_identity = None
self.entitlements_file = None
# .app bundle identifier for Code Signing
self.bundle_identifier = kwargs.get('bundle_identifier')
if not self.bundle_identifier:
# Fallback to appname.
self.bundle_identifier = self.appname
self.info_plist = kwargs.get('info_plist', None)
for arg in args:
# Valid arguments: EXE object, COLLECT object, and TOC-like iterables
if isinstance(arg, EXE):
# Add EXE as an entry to the TOC, and merge its dependencies TOC
self.toc.append((os.path.basename(arg.name), arg.name, 'EXECUTABLE'))
self.toc.extend(arg.dependencies)
# Inherit settings
self.strip = arg.strip
self.upx = arg.upx
self.upx_exclude = arg.upx_exclude
self.console = arg.console
self.target_arch = arg.target_arch
self.codesign_identity = arg.codesign_identity
self.entitlements_file = arg.entitlements_file
elif isinstance(arg, COLLECT):
# Merge the TOC
self.toc.extend(arg.toc)
# Inherit settings
self.strip = arg.strip_binaries
self.upx = arg.upx_binaries
self.upx_exclude = arg.upx_exclude
self.console = arg.console
self.target_arch = arg.target_arch
self.codesign_identity = arg.codesign_identity
self.entitlements_file = arg.entitlements_file
elif miscutils.is_iterable(arg):
# TOC-like iterable
self.toc.extend(arg)
else:
raise TypeError(f"Invalid argument type for BUNDLE: {type(arg)!r}")
# Infer the executable name from the first EXECUTABLE entry in the TOC; it might have come from the COLLECT
# (as opposed to the stand-alone EXE).
for dest_name, src_name, typecode in self.toc:
if typecode == "EXECUTABLE":
self.exename = src_name
break
else:
raise ValueError("No EXECUTABLE entry found in the TOC!")
# Normalize TOC
self.toc = normalize_toc(self.toc)
self.__postinit__()
_GUTS = (
# BUNDLE always builds, just want the toc to be written out
('toc', None),
)
def _check_guts(self, data, last_build):
# BUNDLE always needs to be executed, in order to clean the output directory.
return True
# Helper for determining whether the given file belongs to a .framework bundle or not. If it does, it returns
# the path to the top-level .framework bundle directory; otherwise, returns None.
@staticmethod
def _is_framework_file(dest_path):
for parent in dest_path.parents:
if parent.name.endswith('.framework'):
return parent
return None
# Helper that computes relative cross-link path between link's location and target, assuming they are both
# rooted in the `Contents` directory of a macOS .app bundle.
@staticmethod
def _compute_relative_crosslink(crosslink_location, crosslink_target):
# We could take symlink_location and symlink_target as they are (relative to parent of the `Contents`
# directory), but that would introduce an unnecessary `../Contents` part. So instead, we take both paths
# relative to the `Contents` directory.
return os.path.join(
*['..' for level in pathlib.PurePath(crosslink_location).relative_to('Contents').parent.parts],
pathlib.PurePath(crosslink_target).relative_to('Contents'),
)
# This method takes the original (input) TOC and processes it into final TOC, based on which the `assemble` method
# performs its file collection. The TOC processing here represents the core of our efforts to generate an .app
# bundle that is compatible with Apple's code-signing requirements.
#
# For in-depth details on the code-signing, see Apple's `Technical Note TN2206: macOS Code Signing In Depth` at
# https://developer.apple.com/library/archive/technotes/tn2206/_index.html
#
# The requirements, framed from PyInstaller's perspective, can be summarized as follows:
#
# 1. The `Contents/MacOS` directory is expected to contain only the program executable and (binary) code (= dylibs
# and nested .framework bundles). Alternatively, the dylibs and .framework bundles can be also placed into
# `Contents/Frameworks` directory (where same rules apply as for `Contents/MacOS`, so the remainder of this
# text refers to the two inter-changeably, unless explicitly noted otherwise). The code in `Contents/MacOS`
# is expected to be signed, and the `codesign` utility will recursively sign all found code when using `--deep`
# option to sign the .app bundle.
#
# 2. All non-code files should be be placed in `Contents/Resources`, so they become sealed (data) resources;
# i.e., their signature data is recorded in `Contents/_CodeSignature/CodeResources`. (As a side note,
# it seems that signature information for data/resources in `Contents/Resources` is kept nder `file` key in
# the `CodeResources` file, while the information for contents in `Contents/MacOS` is kept under `file2` key).
#
# 3. The directories in `Contents/MacOS` may not contain dots (`.`) in their names, except for the nested
# .framework bundle directories. The directories in `Contents/Resources` have no such restrictions.
#
# 4. There may not be any content in the top level of a bundle. In other words, if a bundle has a `Contents`
# or a `Versions` directory at its top level, there may be no other files or directories alongside them. The
# sole exception is that alongside ˙Versions˙, there may be symlinks to files and directories in
# `Versions/Current`. This rule is important for nested .framework bundles that we collect from python packages.
#
# Next, let us consider the consequences of violating each of the above requirements:
#
# 1. Code signing machinery can directly store signature only in Mach-O binaries and nested .framework bundles; if
# a data file is placed in `Contents/MacOS`, the signature is stored in the file's extended attributes. If the
# extended attributes are lost, the program's signature will be broken. Many file transfer techniques (e.g., a
# zip file) do not preserve extended attributes, nor are they preserved when uploading to the Mac App Store.
#
# 2. Putting code (a dylib or a .framework bundle) into `Contents/Resources` causes it to be treated as a resource;
# the outer signature (i.e., of the whole .app bundle) does not know that this nested content is actually a code.
# Consequently, signing the bundle with ˙codesign --deep` will NOT sign binaries placed in the
# `Contents/Resources`, which may result in missing signatures when .app bundle is verified for notarization.
# This might be worked around by signing each binary separately, and then signing the whole bundle (without the
# `--deep˙ option), but requires the user to keep track of the offending binaries.
#
# 3. If a directory in `Contents/MacOS` contains a dot in the name, code-signing the bundle fails with
# ˙bundle format unrecognized, invalid, or unsuitable` due to code signing machinery treating directory as a
# nested .framework bundle directory.
#
# 4. If nested .framework bundle is malformed, the signing of the .app bundle might succeed, but subsequent
# verification will fail, for example with `embedded framework contains modified or invalid version` (as observed
# with .framework bundles shipped by contemporary PyQt/PySide PyPI wheels).
#
# The above requirements are unfortunately often at odds with the structure of python packages:
#
# * In general, python packages are mixed-content directories, where binaries and data files may be expected to
# be found next to each other.
#
# For example, `opencv-python` provides a custom loader script that requires the package to be collected in the
# source-only form by PyInstaller (i.e., the python modules and scripts collected as source .py files). At the
# same time, it expects the .py loader script to be able to find the binary extension next to itself.
#
# Another example of mixed-mode directories are Qt QML components' sub-directories, which contain both the
# component's plugin (a binary) and associated meta files (data files).
#
# * In python world, the directories often contain dots in their names.
#
# Dots are often used for private directories containing binaries that are shipped with a package. For example,
# `numpy/.dylibs`, `scipy/.dylibs`, etc.
#
# Qt QML components may also contain a dot in their name; couple of examples from `PySide2` package:
# `PySide2/Qt/qml/QtQuick.2`, ˙PySide2/Qt/qml/QtQuick/Controls.2˙, ˙PySide2/Qt/qml/QtQuick/Particles.2˙, etc.
#
# The packages' metadata directories also invariably contain dots in the name due to version (for example,
# `numpy-1.24.3.dist-info`).
#
# In the light of all above, PyInstaller attempts to strictly place all files to their mandated location
# (`Contents/MacOS` or `Contents/Frameworks` vs `Contents/Resources`). To preserve the illusion of mixed-content
# directories, the content is cross-linked from one directory to the other. Specifically:
#
# * All entries with DATA typecode are assumed to be data files, and are always placed in corresponding directory
# structure rooted in `Contents/Resources`.
#
# * All entries with BINARY or EXTENSION typecode are always placed in corresponding directory structure rooted in
# `Contents/Frameworks`.
#
# * All entries with EXECUTABLE are placed in `Contents/MacOS` directory.
#
# * For the purposes of relocation, nested .framework bundles are treated as a single BINARY entity; i.e., the
# whole .bundle directory is placed in corresponding directory structure rooted in `Contents/Frameworks` (even
# though some of its contents, such as `Info.plist` file, are actually data files).
#
# * Top-level data files and binaries are always cross-linked to the other directory. For example, given a data file
# `data_file.txt` that was collected into `Contents/Resources`, we create a symbolic link called
# `Contents/MacOS/data_file.txt` that points to `../Resources/data_file.txt`.
#
# * The executable itself, while placed in `Contents/MacOS`, are cross-linked into both `Contents/Framworks` and
# `Contents/Resources`.
#
# * The stand-alone PKG entries (used with onefile builds that side-load the PKG archive) are treated as data files
# and collected into `Contents/Resources`, but cross-linked only into `Contents/MacOS` directory (because they
# must appear to be next to the program executable). This is the only entry type that is cross-linked into the
# `Contents/MacOS` directory and also the only data-like entry type that is not cross-linked into the
# `Contents/Frameworks` directory.
#
# * For files in sub-directories, the cross-linking behavior depends on the type of directory:
#
# * A data-only directory is created in directory structure rooted in `Contents/Resources`, and cross-linked
# into directory structure rooted in `Contents/Frameworks` at directory level (i.e., we link the whole
# directory instead of individual files).
#
# This largely saves us from having to deal with dots in the names of collected metadata directories, which
# are examples of data-only directories.
#
# * A binary-only directory is created in directory structure rooted in `Contents/Frameworks`, and cross-linked
# into `Contents/Resources` at directory level.
#
# * A mixed-content directory is created in both directory structures. Files are placed into corresponding
# directory structure based on their type, and cross-linked into other directory structure at file level.
#
# * This rule is applied recursively; for example, a data-only sub-directory in a mixed-content directory is
# cross-linked at directory level, while adjacent binary and data files are cross-linked at file level.
#
# * To work around the issue with dots in the names of directories in `Contents/Frameworks` (applicable to
# binary-only or mixed-content directories), such directories are created with modified name (the dot replaced
# with a pre-defined pattern). Next to the modified directory, a symbolic link with original name is created,
# pointing to the directory with modified name. With mixed-content directories, this modification is performed
# only on the `Contents/Frameworks` side; the corresponding directory in `Contents/Resources` can be created
# directly, without name modification and symbolic link.
#
# * If a symbolic link needs to be created in a mixed-content directory due to a SYMLINK entry from the original
# TOC (i.e., a "collected" symlink originating from analysis, as opposed to the cross-linking mechanism described
# above), the link is created in both directory structures, each pointing to the resource in its corresponding
# directory structure (with one such resource being an actual file, and the other being a cross-link to the file).
#
# Final remarks:
#
# NOTE: the relocation mechanism is codified by tests in `tests/functional/test_macos_bundle_structure.py`.
#
# NOTE: by placing binaries and nested .framework entries into `Contents/Frameworks` instead of `Contents/MacOS`,
# we have effectively relocated the `sys._MEIPASS` directory from the `Contents/MacOS` (= the parent directory of
# the program executable) into `Contents/Frameworks`. This requires the PyInstaller's bootloader to detect that it
# is running in the app-bundle mode (e.g., by checking if program executable's parent directory is `Contents/NacOS`)
# and adjust the path accordingly.
#
# NOTE: the implemented relocation mechanism depends on the input TOC containing properly classified entries
# w.r.t. BINARY vs DATA. So hooks and .spec files triggering collection of binaries as datas (and vice versa) will
# result in incorrect placement of those files in the generated .app bundle. However, this is *not* the proper place
# to address such issues; if necessary, automatic (re)classification should be added to analysis process, to ensure
# that BUNDLE (as well as other build targets) receive correctly classified TOC.
#
# NOTE: similar to the previous note, the relocation mechanism is also not the proper place to enforce compliant
# structure of the nested .framework bundles. Instead, this is handled by the analysis process, using the
# `PyInstaller.utils.osx.collect_files_from_framework_bundles` helper function. So the input TOC that BUNDLE
# receives should already contain entries that reconstruct compliant nested .framework bundles.
def _process_bundle_toc(self, toc):
bundle_toc = []
# Step 1: inspect the directory layout and classify the directories according to their contents.
directory_types = dict()
_MIXED_DIR_TYPE = 'MIXED-DIR'
_DATA_DIR_TYPE = 'DATA-DIR'
_BINARY_DIR_TYPE = 'BINARY-DIR'
_FRAMEWORK_DIR_TYPE = 'FRAMEWORK-DIR'
_TOP_LEVEL_DIR = pathlib.PurePath('.')
for dest_name, src_name, typecode in toc:
dest_path = pathlib.PurePath(dest_name)
framework_dir = self._is_framework_file(dest_path)
if framework_dir:
# Mark the framework directory as FRAMEWORK-DIR.
directory_types[framework_dir] = _FRAMEWORK_DIR_TYPE
# Treat the framework directory as BINARY file when classifying parent directories.
typecode = 'BINARY'
parent_dirs = framework_dir.parents
else:
parent_dirs = dest_path.parents
# Treat BINARY and EXTENSION as BINARY to simplify further processing.
if typecode == 'EXTENSION':
typecode = 'BINARY'
# (Re)classify parent directories
for parent_dir in parent_dirs:
# Skip the top-level `.` dir. This is also the only directory that can contain EXECUTABLE and PKG
# entries, so we do not have to worry about.
if parent_dir == _TOP_LEVEL_DIR:
continue
directory_type = _BINARY_DIR_TYPE if typecode == 'BINARY' else _DATA_DIR_TYPE # default
directory_type = directory_types.get(parent_dir, directory_type)
if directory_type == _DATA_DIR_TYPE and typecode == 'BINARY':
directory_type = _MIXED_DIR_TYPE
if directory_type == _BINARY_DIR_TYPE and typecode == 'DATA':
directory_type = _MIXED_DIR_TYPE
directory_types[parent_dir] = directory_type
logger.debug("Directory classification: %r", directory_types)
# Step 2: process the obtained directory structure and create symlink entries for directories that need to be
# cross-linked. Such directories are data-only and binary-only directories (and framework directories) that are
# located either in the top-level directory (have no parent) or in a mixed-content directory.
for directory_path, directory_type in directory_types.items():
# Cross-linking at directory level applies only to data-only and binary-only directories (as well as
# framework directories).
if directory_type == _MIXED_DIR_TYPE:
continue
# The parent needs to be either top-level directory or a mixed-content directory. Otherwise, the parent
# (or one of its ancestors) will get cross-linked, and we do not need the link here.
parent_dir = directory_path.parent
requires_crosslink = parent_dir == _TOP_LEVEL_DIR or directory_types.get(parent_dir) == _MIXED_DIR_TYPE
if not requires_crosslink:
continue
logger.debug("Cross-linking directory %r of type %r", directory_path, directory_type)
# Data-only directories are created in `Contents/Resources`, needs to be cross-linked into `Contents/MacOS`.
# Vice versa for binary-only or framework directories. The directory creation is handled implicitly, when we
# create parent directory structure for collected files.
if directory_type == _DATA_DIR_TYPE:
symlink_src = os.path.join('Contents/Resources', directory_path)
symlink_dest = os.path.join('Contents/Frameworks', directory_path)
else:
symlink_src = os.path.join('Contents/Frameworks', directory_path)
symlink_dest = os.path.join('Contents/Resources', directory_path)
symlink_ref = self._compute_relative_crosslink(symlink_dest, symlink_src)
bundle_toc.append((symlink_dest, symlink_ref, 'SYMLINK'))
# Step 3: first part of the work-around for directories that are located in `Contents/Frameworks` but contain a
# dot in their name. As per `codesign` rules, the only directories in `Contents/Frameworks` that are allowed to
# contain a dot in their name are .framework bundle directories. So we replace the dot with a custom character
# sequence (stored in global `DOT_REPLACEMENT` variable), and create a symbolic with original name pointing to
# the modified name. This is the best we can do with code-sign requirements vs. python community showing their
# packages' dylibs into `.dylib` subdirectories, or Qt storing their Qml components in directories named
# `QtQuick.2`, `QtQuick/Controls.2`, `QtQuick/Particles.2`, `QtQuick/Templates.2`, etc.
#
# In this step, we only prepare symlink entries that link the original directory name (with dot) to the modified
# one (with dot replaced). The parent paths for collected files are modified in later step(s).
for directory_path, directory_type in directory_types.items():
# .framework bundle directories contain a dot in the name, but are allowed that.
if directory_type == _FRAMEWORK_DIR_TYPE:
continue
# Data-only directories are fully located in `Contents/Resources` and cross-linked to `Contents/Frameworks`
# at directory level, so they are also allowed a dot in their name.
if directory_type == _DATA_DIR_TYPE:
continue
# Apply the work-around, if necessary...
if '.' not in directory_path.name:
continue
logger.debug(
"Creating symlink to work around the dot in the name of directory %r (%s)...", str(directory_path),
directory_type
)
# Create a SYMLINK entry, but only for this level. In case of nested directories with dots in names, the
# symlinks for ancestors will be created by corresponding loop iteration.
bundle_toc.append((
os.path.join('Contents/Frameworks', directory_path),
directory_path.name.replace('.', DOT_REPLACEMENT),
'SYMLINK',
))
# Step 4: process the entries for collected files, and decide whether they should be placed into
# `Contents/MacOS`, `Contents/Frameworks`, or `Contents/Resources`, and whether they should be cross-linked into
# other directories.
for orig_dest_name, src_name, typecode in toc:
orig_dest_path = pathlib.PurePath(orig_dest_name)
# Special handling for EXECUTABLE and PKG entries
if typecode == 'EXECUTABLE':
# Place into `Contents/MacOS`, ...
file_dest = os.path.join('Contents/MacOS', orig_dest_name)
bundle_toc.append((file_dest, src_name, typecode))
# ... and do nothing else. We explicitly avoid cross-linking the executable to `Contents/Frameworks` and
# `Contents/Resources`, because it should be not necessary (the executable's location should be
# discovered via `sys.executable`) and to prevent issues when executable name collides with name of a
# package from which we collect either binaries or data files (or both); see #7314.
continue
elif typecode == 'PKG':
# Place into `Contents/Resources` ...
file_dest = os.path.join('Contents/Resources', orig_dest_name)
bundle_toc.append((file_dest, src_name, typecode))
# ... and cross-link only into `Contents/MacOS`.
# This is used only in `onefile` mode, where there is actually no other content to distribute among the
# `Contents/Resources` and `Contents/Frameworks` directories, so cross-linking into the latter makes
# little sense.
symlink_dest = os.path.join('Contents/MacOS', orig_dest_name)
symlink_ref = self._compute_relative_crosslink(symlink_dest, file_dest)
bundle_toc.append((symlink_dest, symlink_ref, 'SYMLINK'))
continue
# Standard data vs binary processing...
# Determine file location based on its type.
if self._is_framework_file(orig_dest_path):
# File from a framework bundle; put into `Contents/Frameworks`, but never cross-link the file itself.
# The whole .framework bundle directory will be linked as necessary by the directory cross-linking
# mechanism.
file_base_dir = 'Contents/Frameworks'
crosslink_base_dir = None
elif typecode == 'DATA':
# Data file; relocate to `Contents/Resources` and cross-link it back into `Contents/Frameworks`.
file_base_dir = 'Contents/Resources'
crosslink_base_dir = 'Contents/Frameworks'
else:
# Binary; put into `Contents/Frameworks` and cross-link it into `Contents/Resources`.
file_base_dir = 'Contents/Frameworks'
crosslink_base_dir = 'Contents/Resources'
# Determine if we need to cross-link the file. We need to do this for top-level files (the ones without
# parent directories), and for files whose parent directories are mixed-content directories.
requires_crosslink = False
if crosslink_base_dir is not None:
parent_dir = orig_dest_path.parent
requires_crosslink = parent_dir == _TOP_LEVEL_DIR or directory_types.get(parent_dir) == _MIXED_DIR_TYPE
# Special handling for SYMLINK entries in original TOC; if we need to cross-link a symlink entry, we create
# it in both locations, and have each point to the (relative) resource in the same directory (so one of the
# targets will likely be a file, and the other will be a symlink due to cross-linking).
if typecode == 'SYMLINK' and requires_crosslink:
bundle_toc.append((os.path.join(file_base_dir, orig_dest_name), src_name, typecode))
bundle_toc.append((os.path.join(crosslink_base_dir, orig_dest_name), src_name, typecode))
continue
# The file itself.
file_dest = os.path.join(file_base_dir, orig_dest_name)
bundle_toc.append((file_dest, src_name, typecode))
# Symlink for cross-linking
if requires_crosslink:
symlink_dest = os.path.join(crosslink_base_dir, orig_dest_name)
symlink_ref = self._compute_relative_crosslink(symlink_dest, file_dest)
bundle_toc.append((symlink_dest, symlink_ref, 'SYMLINK'))
# Step 5: sanitize all destination paths in the new TOC, to ensure that paths that are rooted in
# `Contents/Frameworks` do not contain directories with dots in their names. Doing this as a post-processing
# step keeps code simple and clean and ensures that this step is applied to files, symlinks that originate from
# cross-linking files, and symlinks that originate from cross-linking directories. This in turn ensures that
# all directory hierarchies created during the actual file collection have sanitized names, and that collection
# outcome does not depend on the order of entries in the TOC.
sanitized_toc = []
for dest_name, src_name, typecode in bundle_toc:
dest_path = pathlib.PurePath(dest_name)
# Paths rooted in Contents/Resources do not require sanitizing.
if dest_path.parts[0] == 'Contents' and dest_path.parts[1] == 'Resources':
sanitized_toc.append((dest_name, src_name, typecode))
continue
# Special handling for files from .framework bundle directories; sanitize only parent path of the .framework
# directory.
framework_path = self._is_framework_file(dest_path)
if framework_path:
parent_path = framework_path.parent
remaining_path = dest_path.relative_to(parent_path)
else:
parent_path = dest_path.parent
remaining_path = dest_path.name
sanitized_dest_path = pathlib.PurePath(
*parent_path.parts[:2], # Contents/Frameworks
*[part.replace('.', DOT_REPLACEMENT) for part in parent_path.parts[2:]],
remaining_path,
)
sanitized_dest_name = str(sanitized_dest_path)
if sanitized_dest_path != dest_path:
logger.debug("Sanitizing dest path: %r -> %r", dest_name, sanitized_dest_name)
sanitized_toc.append((sanitized_dest_name, src_name, typecode))
bundle_toc = sanitized_toc
# Normalize and sort the TOC for easier inspection
bundle_toc = sorted(normalize_toc(bundle_toc))
return bundle_toc
def assemble(self):
from PyInstaller.config import CONF
if _check_path_overlap(self.name) and os.path.isdir(self.name):
_rmtree(self.name)
logger.info("Building BUNDLE %s", self.tocbasename)
# Create a minimal Mac bundle structure.
os.makedirs(os.path.join(self.name, "Contents", "MacOS"))
os.makedirs(os.path.join(self.name, "Contents", "Resources"))
os.makedirs(os.path.join(self.name, "Contents", "Frameworks"))
# Makes sure the icon exists and attempts to convert to the proper format if applicable
self.icon = normalize_icon_type(self.icon, ("icns",), "icns", CONF["workpath"])
# Ensure icon path is absolute
self.icon = os.path.abspath(self.icon)
# Copy icns icon to Resources directory.
shutil.copyfile(self.icon, os.path.join(self.name, 'Contents', 'Resources', os.path.basename(self.icon)))
# Key/values for a minimal Info.plist file
info_plist_dict = {
"CFBundleDisplayName": self.appname,
"CFBundleName": self.appname,
# Required by 'codesign' utility.
# The value for CFBundleIdentifier is used as the default unique name of your program for Code Signing
# purposes. It even identifies the APP for access to restricted OS X areas like Keychain.
#
# The identifier used for signing must be globally unique. The usual form for this identifier is a
# hierarchical name in reverse DNS notation, starting with the toplevel domain, followed by the company
# name, followed by the department within the company, and ending with the product name. Usually in the
# form: com.mycompany.department.appname
# CLI option --osx-bundle-identifier sets this value.
"CFBundleIdentifier": self.bundle_identifier,
"CFBundleExecutable": os.path.basename(self.exename),
"CFBundleIconFile": os.path.basename(self.icon),
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundlePackageType": "APPL",
"CFBundleShortVersionString": self.version,
}
# Set some default values. But they still can be overwritten by the user.
if self.console:
# Setting EXE console=True implies LSBackgroundOnly=True.
info_plist_dict['LSBackgroundOnly'] = True
else:
# Let's use high resolution by default.
info_plist_dict['NSHighResolutionCapable'] = True
# Merge info_plist settings from spec file
if isinstance(self.info_plist, dict) and self.info_plist:
info_plist_dict.update(self.info_plist)
plist_filename = os.path.join(self.name, "Contents", "Info.plist")
with open(plist_filename, "wb") as plist_fh:
plistlib.dump(info_plist_dict, plist_fh)
# Pre-process the TOC into its final BUNDLE-compatible form.
bundle_toc = self._process_bundle_toc(self.toc)
# Perform the actual collection.
CONTENTS_FRAMEWORKS_PATH = pathlib.PurePath('Contents/Frameworks')
for dest_name, src_name, typecode in bundle_toc:
# Create parent directory structure, if necessary
dest_path = os.path.join(self.name, dest_name) # Absolute destination path
dest_dir = os.path.dirname(dest_path)
try:
os.makedirs(dest_dir, exist_ok=True)
except FileExistsError:
raise SystemExit(
f"Pyinstaller needs to create a directory at {dest_dir!r}, "
"but there already exists a file at that path!"
)
# Copy extensions and binaries from cache. This ensures that these files undergo additional binary
# processing - have paths to linked libraries rewritten (relative to `@rpath`) and have rpath set to the
# top-level directory (relative to `@loader_path`, i.e., the file's location). The "top-level" directory
# in this case corresponds to `Contents/MacOS` (where `sys._MEIPASS` also points), so we need to pass
# the cache retrieval function the *original* destination path (which is without preceding
# `Contents/MacOS`).
if typecode in ('EXTENSION', 'BINARY'):
orig_dest_name = str(pathlib.PurePath(dest_name).relative_to(CONTENTS_FRAMEWORKS_PATH))
src_name = process_collected_binary(
src_name,
orig_dest_name,
use_strip=self.strip,
use_upx=self.upx,
upx_exclude=self.upx_exclude,
target_arch=self.target_arch,
codesign_identity=self.codesign_identity,
entitlements_file=self.entitlements_file,
strict_arch_validation=(typecode == 'EXTENSION'),
)
if typecode == 'SYMLINK':
os.symlink(src_name, dest_path) # Create link at dest_path, pointing at (relative) src_name
else:
# BUNDLE does not support MERGE-based multipackage
assert typecode != 'DEPENDENCY', "MERGE DEPENDENCY entries are not supported in BUNDLE!"
# At this point, `src_name` should be a valid file.
if not os.path.isfile(src_name):
raise ValueError(f"Resource {src_name!r} is not a valid file!")
# If strict collection mode is enabled, the destination should not exist yet.
if strict_collect_mode and os.path.exists(dest_path):
raise ValueError(
f"Attempting to collect a duplicated file into BUNDLE: {dest_name} (type: {typecode})"
)
# Use `shutil.copyfile` to copy file with default permissions. We do not attempt to preserve original
# permissions nor metadata, as they might be too restrictive and cause issues either during subsequent
# re-build attempts or when trying to move the application bundle. For binaries (and data files with
# executable bit set), we manually set the executable bits after copying the file.
shutil.copyfile(src_name, dest_path)
if (
typecode in ('EXTENSION', 'BINARY', 'EXECUTABLE')
or (typecode == 'DATA' and os.access(src_name, os.X_OK))
):
os.chmod(dest_path, 0o755)
# Sign the bundle
logger.info('Signing the BUNDLE...')
try:
osxutils.sign_binary(self.name, self.codesign_identity, self.entitlements_file, deep=True)
except Exception as e:
# Display a warning or re-raise the error, depending on the environment-variable setting.
if os.environ.get("PYINSTALLER_STRICT_BUNDLE_CODESIGN_ERROR", "0") == "0":
logger.warning("Error while signing the bundle: %s", e)
logger.warning("You will need to sign the bundle manually!")
else:
raise RuntimeError("Failed to codesign the bundle!") from e
logger.info("Building BUNDLE %s completed successfully.", self.tocbasename)
# Optionally verify bundle's signature. This is primarily intended for our CI.
if os.environ.get("PYINSTALLER_VERIFY_BUNDLE_SIGNATURE", "0") != "0":
logger.info("Verifying signature for BUNDLE %s...", self.name)
self.verify_bundle_signature(self.name)
logger.info("BUNDLE verification complete!")
@staticmethod
def verify_bundle_signature(bundle_dir):
# First, verify the bundle signature using codesign.
cmd_args = ['codesign', '--verify', '--all-architectures', '--deep', '--strict', bundle_dir]
p = subprocess.run(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True)
if p.returncode:
raise SystemError(
f"codesign command ({cmd_args}) failed with error code {p.returncode}!\noutput: {p.stdout}"
)
# Ensure that code-signing information is *NOT* embedded in the files' extended attributes.
#
# This happens when files other than binaries are present in `Contents/MacOS` or `Contents/Frameworks`
# directory; as the signature cannot be embedded within the file itself (contrary to binaries with
# `LC_CODE_SIGNATURE` section in their header), it ends up stores in the file's extended attributes. However,
# if such bundle is transferred using a method that does not support extended attributes (for example, a zip
# file), the signatures on these files are lost, and the signature of the bundle as a whole becomes invalid.
# This is the primary reason why we need to relocate non-binaries into `Contents/Resources` - the signatures
# for files in that directory end up stored in `Contents/_CodeSignature/CodeResources` file.
#
# This check therefore aims to ensure that all files have been properly relocated to their corresponding
# locations w.r.t. the code-signing requirements.
try:
import xattr
except ModuleNotFoundError:
logger.info("xattr package not available; skipping verification of extended attributes!")
return
CODESIGN_ATTRS = (
"com.apple.cs.CodeDirectory",
"com.apple.cs.CodeRequirements",
"com.apple.cs.CodeRequirements-1",
"com.apple.cs.CodeSignature",
)
for entry in pathlib.Path(bundle_dir).rglob("*"):
if not entry.is_file():
continue
file_attrs = xattr.listxattr(entry)
if any([codesign_attr in file_attrs for codesign_attr in CODESIGN_ATTRS]):
raise ValueError(f"Code-sign attributes found in extended attributes of {str(entry)!r}!")