macdeployqtplus raw
1 #!/usr/bin/env python3
2 #
3 # Copyright (C) 2011 Patrick "p2k" Schneider <me@p2k-network.org>
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
17 #
18
19 import sys, re, os, platform, shutil, stat, subprocess, os.path
20 from argparse import ArgumentParser
21 from pathlib import Path
22 from subprocess import PIPE, run
23 from typing import Optional
24
25 # This is ported from the original macdeployqt with modifications
26
27 class FrameworkInfo(object):
28 def __init__(self):
29 self.frameworkDirectory = ""
30 self.frameworkName = ""
31 self.frameworkPath = ""
32 self.binaryDirectory = ""
33 self.binaryName = ""
34 self.binaryPath = ""
35 self.version = ""
36 self.installName = ""
37 self.deployedInstallName = ""
38 self.sourceFilePath = ""
39 self.destinationDirectory = ""
40 self.sourceResourcesDirectory = ""
41 self.sourceVersionContentsDirectory = ""
42 self.sourceContentsDirectory = ""
43 self.destinationResourcesDirectory = ""
44 self.destinationVersionContentsDirectory = ""
45
46 def __eq__(self, other):
47 if self.__class__ == other.__class__:
48 return self.__dict__ == other.__dict__
49 else:
50 return False
51
52 def __str__(self):
53 return f""" Framework name: {self.frameworkName}
54 Framework directory: {self.frameworkDirectory}
55 Framework path: {self.frameworkPath}
56 Binary name: {self.binaryName}
57 Binary directory: {self.binaryDirectory}
58 Binary path: {self.binaryPath}
59 Version: {self.version}
60 Install name: {self.installName}
61 Deployed install name: {self.deployedInstallName}
62 Source file Path: {self.sourceFilePath}
63 Deployed Directory (relative to bundle): {self.destinationDirectory}
64 """
65
66 def isDylib(self):
67 return self.frameworkName.endswith(".dylib")
68
69 def isQtFramework(self):
70 if self.isDylib():
71 return self.frameworkName.startswith("libQt")
72 else:
73 return self.frameworkName.startswith("Qt")
74
75 reOLine = re.compile(r'^(.+) \(compatibility version [0-9.]+, current version [0-9.]+\)$')
76 bundleFrameworkDirectory = "Contents/Frameworks"
77 bundleBinaryDirectory = "Contents/MacOS"
78
79 @classmethod
80 def fromLibraryLine(cls, line: str) -> Optional['FrameworkInfo']:
81 # Note: line must be trimmed
82 if line == "":
83 return None
84
85 # Don't deploy system libraries
86 if line.startswith("/System/Library/") or line.startswith("@executable_path") or line.startswith("/usr/lib/"):
87 return None
88
89 m = cls.reOLine.match(line)
90 if m is None:
91 raise RuntimeError(f"Line could not be parsed: {line}")
92
93 path = m.group(1)
94
95 info = cls()
96 info.sourceFilePath = path
97 info.installName = path
98
99 if path.endswith(".dylib"):
100 dirname, filename = os.path.split(path)
101 info.frameworkName = filename
102 info.frameworkDirectory = dirname
103 info.frameworkPath = path
104
105 info.binaryDirectory = dirname
106 info.binaryName = filename
107 info.binaryPath = path
108 info.version = "-"
109
110 info.installName = path
111 info.deployedInstallName = f"@executable_path/../Frameworks/{info.binaryName}"
112 info.sourceFilePath = path
113 info.destinationDirectory = cls.bundleFrameworkDirectory
114 else:
115 parts = path.split("/")
116 i = 0
117 # Search for the .framework directory
118 for part in parts:
119 if part.endswith(".framework"):
120 break
121 i += 1
122 if i == len(parts):
123 raise RuntimeError(f"Could not find .framework or .dylib in line: {line}")
124
125 info.frameworkName = parts[i]
126 info.frameworkDirectory = "/".join(parts[:i])
127 info.frameworkPath = os.path.join(info.frameworkDirectory, info.frameworkName)
128
129 info.binaryName = parts[i+3]
130 info.binaryDirectory = "/".join(parts[i+1:i+3])
131 info.binaryPath = os.path.join(info.binaryDirectory, info.binaryName)
132 info.version = parts[i+2]
133
134 info.deployedInstallName = f"@executable_path/../Frameworks/{os.path.join(info.frameworkName, info.binaryPath)}"
135 info.destinationDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, info.binaryDirectory)
136
137 info.sourceResourcesDirectory = os.path.join(info.frameworkPath, "Resources")
138 info.sourceContentsDirectory = os.path.join(info.frameworkPath, "Contents")
139 info.sourceVersionContentsDirectory = os.path.join(info.frameworkPath, "Versions", info.version, "Contents")
140 info.destinationResourcesDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, "Resources")
141 info.destinationVersionContentsDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, "Versions", info.version, "Contents")
142
143 return info
144
145 class ApplicationBundleInfo(object):
146 def __init__(self, path: str):
147 self.path = path
148 # for backwards compatibility reasons, this must remain as Bitcoin-Qt
149 self.binaryPath = os.path.join(path, "Contents", "MacOS", "Bitcoin-Qt")
150 if not os.path.exists(self.binaryPath):
151 raise RuntimeError(f"Could not find bundle binary for {path}")
152 self.resourcesPath = os.path.join(path, "Contents", "Resources")
153 self.pluginPath = os.path.join(path, "Contents", "PlugIns")
154
155 class DeploymentInfo(object):
156 def __init__(self):
157 self.qtPath = None
158 self.pluginPath = None
159 self.deployedFrameworks = []
160
161 def detectQtPath(self, frameworkDirectory: str):
162 parentDir = os.path.dirname(frameworkDirectory)
163 if os.path.exists(os.path.join(parentDir, "share", "qt", "plugins")):
164 self.qtPath = parentDir
165 else:
166 self.qtPath = os.getenv("QTDIR", None)
167
168 if self.qtPath is not None:
169 pluginPath = os.path.join(self.qtPath, "share", "qt", "plugins")
170 if os.path.exists(pluginPath):
171 self.pluginPath = pluginPath
172
173 def usesFramework(self, name: str) -> bool:
174 for framework in self.deployedFrameworks:
175 if framework.endswith(".framework"):
176 if framework.startswith(f"{name}."):
177 return True
178 elif framework.endswith(".dylib"):
179 if framework.startswith(f"lib{name}."):
180 return True
181 return False
182
183 def getFrameworks(binaryPath: str, verbose: int, rpath: str = '') -> list[FrameworkInfo]:
184 objdump = os.getenv("OBJDUMP", "objdump")
185 if verbose:
186 print(f"Inspecting with {objdump}: {binaryPath}")
187 output = run([objdump, "--macho", "--dylibs-used", binaryPath], stdout=PIPE, stderr=PIPE, text=True)
188 if output.returncode != 0:
189 sys.stderr.write(output.stderr)
190 sys.stderr.flush()
191 raise RuntimeError(f"{objdump} failed with return code {output.returncode}")
192
193 lines = output.stdout.split("\n")
194 lines.pop(0) # First line is the inspected binary
195 if ".framework" in binaryPath or binaryPath.endswith(".dylib"):
196 lines.pop(0) # Frameworks and dylibs list themselves as a dependency.
197
198 libraries = []
199 for line in lines:
200 line = line.replace("@loader_path", os.path.dirname(binaryPath))
201 if rpath:
202 line = line.replace("@rpath", rpath)
203 info = FrameworkInfo.fromLibraryLine(line.strip())
204 if info is not None:
205 if verbose:
206 print("Found framework:")
207 print(info)
208 libraries.append(info)
209
210 return libraries
211
212 def runInstallNameTool(action: str, *args):
213 installnametoolbin=os.getenv("INSTALL_NAME_TOOL", "install_name_tool")
214 run([installnametoolbin, "-"+action] + list(args), check=True)
215
216 def changeInstallName(oldName: str, newName: str, binaryPath: str, verbose: int):
217 if verbose:
218 print("Using install_name_tool:")
219 print(" in", binaryPath)
220 print(" change reference", oldName)
221 print(" to", newName)
222 runInstallNameTool("change", oldName, newName, binaryPath)
223
224 def changeIdentification(id: str, binaryPath: str, verbose: int):
225 if verbose:
226 print("Using install_name_tool:")
227 print(" change identification in", binaryPath)
228 print(" to", id)
229 runInstallNameTool("id", id, binaryPath)
230
231 def runStrip(binaryPath: str, verbose: int):
232 stripbin=os.getenv("STRIP", "strip")
233 if verbose:
234 print("Using strip:")
235 print(" stripped", binaryPath)
236 run([stripbin, "-x", binaryPath], check=True)
237
238 def copyFramework(framework: FrameworkInfo, path: str, verbose: int) -> Optional[str]:
239 if framework.sourceFilePath.startswith("Qt"):
240 #standard place for Nokia Qt installer's frameworks
241 fromPath = f"/Library/Frameworks/{framework.sourceFilePath}"
242 else:
243 fromPath = framework.sourceFilePath
244 toDir = os.path.join(path, framework.destinationDirectory)
245 toPath = os.path.join(toDir, framework.binaryName)
246
247 if framework.isDylib():
248 if not os.path.exists(fromPath):
249 raise RuntimeError(f"No file at {fromPath}")
250
251 if os.path.exists(toPath):
252 return None # Already there
253
254 if not os.path.exists(toDir):
255 os.makedirs(toDir)
256
257 shutil.copy2(fromPath, toPath)
258 if verbose:
259 print("Copied:", fromPath)
260 print(" to:", toPath)
261 else:
262 to_dir = os.path.join(path, "Contents", "Frameworks", framework.frameworkName)
263 if os.path.exists(to_dir):
264 return None # Already there
265
266 from_dir = framework.frameworkPath
267 if not os.path.exists(from_dir):
268 raise RuntimeError(f"No directory at {from_dir}")
269
270 shutil.copytree(from_dir, to_dir, symlinks=True)
271 if verbose:
272 print("Copied:", from_dir)
273 print(" to:", to_dir)
274
275 headers_link = os.path.join(to_dir, "Headers")
276 if os.path.exists(headers_link):
277 os.unlink(headers_link)
278
279 headers_dir = os.path.join(to_dir, framework.binaryDirectory, "Headers")
280 if os.path.exists(headers_dir):
281 shutil.rmtree(headers_dir)
282
283 permissions = os.stat(toPath)
284 if not permissions.st_mode & stat.S_IWRITE:
285 os.chmod(toPath, permissions.st_mode | stat.S_IWRITE)
286
287 return toPath
288
289 def deployFrameworks(frameworks: list[FrameworkInfo], bundlePath: str, binaryPath: str, strip: bool, verbose: int, deploymentInfo: Optional[DeploymentInfo] = None) -> DeploymentInfo:
290 if deploymentInfo is None:
291 deploymentInfo = DeploymentInfo()
292
293 while len(frameworks) > 0:
294 framework = frameworks.pop(0)
295 deploymentInfo.deployedFrameworks.append(framework.frameworkName)
296
297 print("Processing", framework.frameworkName, "...")
298
299 # Get the Qt path from one of the Qt frameworks
300 if deploymentInfo.qtPath is None and framework.isQtFramework():
301 deploymentInfo.detectQtPath(framework.frameworkDirectory)
302
303 if framework.installName.startswith("@executable_path") or framework.installName.startswith(bundlePath):
304 print(framework.frameworkName, "already deployed, skipping.")
305 continue
306
307 # install_name_tool the new id into the binary
308 changeInstallName(framework.installName, framework.deployedInstallName, binaryPath, verbose)
309
310 # Copy framework to app bundle.
311 deployedBinaryPath = copyFramework(framework, bundlePath, verbose)
312 # Skip the rest if already was deployed.
313 if deployedBinaryPath is None:
314 continue
315
316 if strip:
317 runStrip(deployedBinaryPath, verbose)
318
319 # install_name_tool it a new id.
320 changeIdentification(framework.deployedInstallName, deployedBinaryPath, verbose)
321 # Check for framework dependencies
322 dependencies = getFrameworks(deployedBinaryPath, verbose, rpath=framework.frameworkDirectory)
323
324 for dependency in dependencies:
325 changeInstallName(dependency.installName, dependency.deployedInstallName, deployedBinaryPath, verbose)
326
327 # Deploy framework if necessary.
328 if dependency.frameworkName not in deploymentInfo.deployedFrameworks and dependency not in frameworks:
329 frameworks.append(dependency)
330
331 return deploymentInfo
332
333 def deployFrameworksForAppBundle(applicationBundle: ApplicationBundleInfo, strip: bool, verbose: int) -> DeploymentInfo:
334 frameworks = getFrameworks(applicationBundle.binaryPath, verbose)
335 if len(frameworks) == 0:
336 print(f"Warning: Could not find any external frameworks to deploy in {applicationBundle.path}.")
337 return DeploymentInfo()
338 else:
339 return deployFrameworks(frameworks, applicationBundle.path, applicationBundle.binaryPath, strip, verbose)
340
341 def deployPlugins(appBundleInfo: ApplicationBundleInfo, deploymentInfo: DeploymentInfo, strip: bool, verbose: int):
342 plugins = []
343 if deploymentInfo.pluginPath is None:
344 return
345 for dirpath, dirnames, filenames in os.walk(deploymentInfo.pluginPath):
346 pluginDirectory = os.path.relpath(dirpath, deploymentInfo.pluginPath)
347
348 if pluginDirectory not in ['styles', 'platforms']:
349 continue
350
351 for pluginName in filenames:
352 pluginPath = os.path.join(pluginDirectory, pluginName)
353
354 if pluginName.split('.')[0] not in ['libqminimal', 'libqcocoa', 'libqmacstyle']:
355 continue
356
357 plugins.append((pluginDirectory, pluginName))
358
359 for pluginDirectory, pluginName in plugins:
360 print("Processing plugin", os.path.join(pluginDirectory, pluginName), "...")
361
362 sourcePath = os.path.join(deploymentInfo.pluginPath, pluginDirectory, pluginName)
363 destinationDirectory = os.path.join(appBundleInfo.pluginPath, pluginDirectory)
364 if not os.path.exists(destinationDirectory):
365 os.makedirs(destinationDirectory)
366
367 destinationPath = os.path.join(destinationDirectory, pluginName)
368 shutil.copy2(sourcePath, destinationPath)
369 if verbose:
370 print("Copied:", sourcePath)
371 print(" to:", destinationPath)
372
373 if strip:
374 runStrip(destinationPath, verbose)
375
376 dependencies = getFrameworks(destinationPath, verbose)
377
378 for dependency in dependencies:
379 changeInstallName(dependency.installName, dependency.deployedInstallName, destinationPath, verbose)
380
381 # Deploy framework if necessary.
382 if dependency.frameworkName not in deploymentInfo.deployedFrameworks:
383 deployFrameworks([dependency], appBundleInfo.path, destinationPath, strip, verbose, deploymentInfo)
384
385 ap = ArgumentParser(description="""Improved version of macdeployqt.
386
387 Outputs a ready-to-deploy app in a folder "dist" and optionally wraps it in a .zip file.
388 Note, that the "dist" folder will be deleted before deploying on each run.
389
390 Optionally, Qt translation files (.qm) can be added to the bundle.""")
391
392 ap.add_argument("app_bundle", nargs=1, metavar="app-bundle", help="application bundle to be deployed")
393 ap.add_argument("-verbose", nargs="?", const=True, help="Output additional debugging information")
394 ap.add_argument("-no-plugins", dest="plugins", action="store_false", default=True, help="skip plugin deployment")
395 ap.add_argument("-no-strip", dest="strip", action="store_false", default=True, help="don't run 'strip' on the binaries")
396 ap.add_argument("-translations-dir", nargs=1, metavar="path", default=None, help="Path to Qt's translations. Base translations will automatically be added to the bundle's resources.")
397 ap.add_argument("-zip", nargs=1, metavar="zip", help="create a .zip containing the app bundle")
398
399 config = ap.parse_args()
400
401 verbose = config.verbose
402
403 # ------------------------------------------------
404
405 app_bundle = config.app_bundle[0]
406
407 if not os.path.exists(app_bundle):
408 sys.stderr.write(f"Error: Could not find app bundle \"{app_bundle}\"\n")
409 sys.exit(1)
410
411 # ------------------------------------------------
412
413 if os.path.exists("dist"):
414 print("+ Removing existing dist folder +")
415 shutil.rmtree("dist")
416
417 # ------------------------------------------------
418
419 target = os.path.join("dist", "Bitcoin-Qt.app")
420
421 print("+ Copying source bundle +")
422 if verbose:
423 print(app_bundle, "->", target)
424
425 os.mkdir("dist")
426 shutil.copytree(app_bundle, target, symlinks=True)
427
428 applicationBundle = ApplicationBundleInfo(target)
429
430 # ------------------------------------------------
431
432 print("+ Deploying frameworks +")
433
434 try:
435 deploymentInfo = deployFrameworksForAppBundle(applicationBundle, config.strip, verbose)
436 if deploymentInfo.qtPath is None:
437 deploymentInfo.qtPath = os.getenv("QTDIR", None)
438 if deploymentInfo.qtPath is None:
439 sys.stderr.write("Warning: Could not detect Qt's path, skipping plugin deployment!\n")
440 config.plugins = False
441 except RuntimeError as e:
442 sys.stderr.write(f"Error: {str(e)}\n")
443 sys.exit(1)
444
445 # ------------------------------------------------
446
447 if config.plugins:
448 print("+ Deploying plugins +")
449
450 try:
451 deployPlugins(applicationBundle, deploymentInfo, config.strip, verbose)
452 except RuntimeError as e:
453 sys.stderr.write(f"Error: {str(e)}\n")
454 sys.exit(1)
455
456 # ------------------------------------------------
457
458 if config.translations_dir:
459 if not Path(config.translations_dir[0]).exists():
460 sys.stderr.write(f"Error: Could not find translation dir \"{config.translations_dir[0]}\"\n")
461 sys.exit(1)
462
463 print("+ Adding Qt translations +")
464
465 translations = Path(config.translations_dir[0])
466
467 regex = re.compile('qt_[a-z]*(.qm|_[A-Z]*.qm)')
468
469 lang_files = [x for x in translations.iterdir() if regex.match(x.name)]
470
471 for file in lang_files:
472 if verbose:
473 print(file.as_posix(), "->", os.path.join(applicationBundle.resourcesPath, file.name))
474 shutil.copy2(file.as_posix(), os.path.join(applicationBundle.resourcesPath, file.name))
475
476 # ------------------------------------------------
477
478 print("+ Installing qt.conf +")
479
480 qt_conf="""[Paths]
481 Translations=Resources
482 Plugins=PlugIns
483 """
484
485 with open(os.path.join(applicationBundle.resourcesPath, "qt.conf"), "wb") as f:
486 f.write(qt_conf.encode())
487
488 # ------------------------------------------------
489
490 if platform.system() == "Darwin":
491 subprocess.check_call(f"codesign --deep --force --sign - {target}", shell=True)
492
493 # ------------------------------------------------
494
495 if config.zip is not None:
496 name = config.zip[0]
497
498 if os.path.exists(name + ".zip"):
499 print("+ Removing existing .zip +")
500 os.unlink(name + ".zip")
501
502 subprocess.check_call(["zip", "-ry", os.path.abspath(name + ".zip"), 'Bitcoin-Qt.app'], cwd='dist')
503
504 # ------------------------------------------------
505
506 print("+ Done +")
507
508 sys.exit(0)
509