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 Limenka-Qt
149 self.binaryPath = os.path.join(path, "Contents", "MacOS", "Limenka-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, "translations")):
164 # Classic layout, e.g. "/usr/local/Trolltech/Qt-4.x.x"
165 self.qtPath = parentDir
166 else:
167 self.qtPath = os.getenv("QTDIR", None)
168
169 if self.qtPath is not None:
170 pluginPath = os.path.join(self.qtPath, "plugins")
171 if os.path.exists(pluginPath):
172 self.pluginPath = pluginPath
173
174 def usesFramework(self, name: str) -> bool:
175 for framework in self.deployedFrameworks:
176 if framework.endswith(".framework"):
177 if framework.startswith(f"{name}."):
178 return True
179 elif framework.endswith(".dylib"):
180 if framework.startswith(f"lib{name}."):
181 return True
182 return False
183
184 def getFrameworks(binaryPath: str, verbose: int) -> list[FrameworkInfo]:
185 objdump = os.getenv("OBJDUMP", "objdump")
186 if verbose:
187 print(f"Inspecting with {objdump}: {binaryPath}")
188 output = run([objdump, "--macho", "--dylibs-used", binaryPath], stdout=PIPE, stderr=PIPE, text=True)
189 if output.returncode != 0:
190 sys.stderr.write(output.stderr)
191 sys.stderr.flush()
192 raise RuntimeError(f"{objdump} failed with return code {output.returncode}")
193
194 lines = output.stdout.split("\n")
195 lines.pop(0) # First line is the inspected binary
196 if ".framework" in binaryPath or binaryPath.endswith(".dylib"):
197 lines.pop(0) # Frameworks and dylibs list themselves as a dependency.
198
199 libraries = []
200 for line in lines:
201 line = line.replace("@loader_path", os.path.dirname(binaryPath))
202 info = FrameworkInfo.fromLibraryLine(line.strip())
203 if info is not None:
204 if verbose:
205 print("Found framework:")
206 print(info)
207 libraries.append(info)
208
209 return libraries
210
211 def runInstallNameTool(action: str, *args):
212 installnametoolbin=os.getenv("INSTALL_NAME_TOOL", "install_name_tool")
213 run([installnametoolbin, "-"+action] + list(args), check=True)
214
215 def changeInstallName(oldName: str, newName: str, binaryPath: str, verbose: int):
216 if verbose:
217 print("Using install_name_tool:")
218 print(" in", binaryPath)
219 print(" change reference", oldName)
220 print(" to", newName)
221 runInstallNameTool("change", oldName, newName, binaryPath)
222
223 def changeIdentification(id: str, binaryPath: str, verbose: int):
224 if verbose:
225 print("Using install_name_tool:")
226 print(" change identification in", binaryPath)
227 print(" to", id)
228 runInstallNameTool("id", id, binaryPath)
229
230 def runStrip(binaryPath: str, verbose: int):
231 stripbin=os.getenv("STRIP", "strip")
232 if verbose:
233 print("Using strip:")
234 print(" stripped", binaryPath)
235 run([stripbin, "-x", binaryPath], check=True)
236
237 def copyFramework(framework: FrameworkInfo, path: str, verbose: int) -> Optional[str]:
238 if framework.sourceFilePath.startswith("Qt"):
239 #standard place for Nokia Qt installer's frameworks
240 fromPath = f"/Library/Frameworks/{framework.sourceFilePath}"
241 else:
242 fromPath = framework.sourceFilePath
243 toDir = os.path.join(path, framework.destinationDirectory)
244 toPath = os.path.join(toDir, framework.binaryName)
245
246 if framework.isDylib():
247 if not os.path.exists(fromPath):
248 raise RuntimeError(f"No file at {fromPath}")
249
250 if os.path.exists(toPath):
251 return None # Already there
252
253 if not os.path.exists(toDir):
254 os.makedirs(toDir)
255
256 shutil.copy2(fromPath, toPath)
257 if verbose:
258 print("Copied:", fromPath)
259 print(" to:", toPath)
260 else:
261 to_dir = os.path.join(path, "Contents", "Frameworks", framework.frameworkName)
262 if os.path.exists(to_dir):
263 return None # Already there
264
265 from_dir = framework.frameworkPath
266 if not os.path.exists(from_dir):
267 raise RuntimeError(f"No directory at {from_dir}")
268
269 shutil.copytree(from_dir, to_dir, symlinks=True)
270 if verbose:
271 print("Copied:", from_dir)
272 print(" to:", to_dir)
273
274 headers_link = os.path.join(to_dir, "Headers")
275 if os.path.exists(headers_link):
276 os.unlink(headers_link)
277
278 headers_dir = os.path.join(to_dir, framework.binaryDirectory, "Headers")
279 if os.path.exists(headers_dir):
280 shutil.rmtree(headers_dir)
281
282 permissions = os.stat(toPath)
283 if not permissions.st_mode & stat.S_IWRITE:
284 os.chmod(toPath, permissions.st_mode | stat.S_IWRITE)
285
286 return toPath
287
288 def deployFrameworks(frameworks: list[FrameworkInfo], bundlePath: str, binaryPath: str, strip: bool, verbose: int, deploymentInfo: Optional[DeploymentInfo] = None) -> DeploymentInfo:
289 if deploymentInfo is None:
290 deploymentInfo = DeploymentInfo()
291
292 while len(frameworks) > 0:
293 framework = frameworks.pop(0)
294 deploymentInfo.deployedFrameworks.append(framework.frameworkName)
295
296 print("Processing", framework.frameworkName, "...")
297
298 # Get the Qt path from one of the Qt frameworks
299 if deploymentInfo.qtPath is None and framework.isQtFramework():
300 deploymentInfo.detectQtPath(framework.frameworkDirectory)
301
302 if framework.installName.startswith("@executable_path") or framework.installName.startswith(bundlePath):
303 print(framework.frameworkName, "already deployed, skipping.")
304 continue
305
306 # install_name_tool the new id into the binary
307 changeInstallName(framework.installName, framework.deployedInstallName, binaryPath, verbose)
308
309 # Copy framework to app bundle.
310 deployedBinaryPath = copyFramework(framework, bundlePath, verbose)
311 # Skip the rest if already was deployed.
312 if deployedBinaryPath is None:
313 continue
314
315 if strip:
316 runStrip(deployedBinaryPath, verbose)
317
318 # install_name_tool it a new id.
319 changeIdentification(framework.deployedInstallName, deployedBinaryPath, verbose)
320 # Check for framework dependencies
321 dependencies = getFrameworks(deployedBinaryPath, verbose)
322
323 for dependency in dependencies:
324 changeInstallName(dependency.installName, dependency.deployedInstallName, deployedBinaryPath, verbose)
325
326 # Deploy framework if necessary.
327 if dependency.frameworkName not in deploymentInfo.deployedFrameworks and dependency not in frameworks:
328 frameworks.append(dependency)
329
330 return deploymentInfo
331
332 def deployFrameworksForAppBundle(applicationBundle: ApplicationBundleInfo, strip: bool, verbose: int) -> DeploymentInfo:
333 frameworks = getFrameworks(applicationBundle.binaryPath, verbose)
334 if len(frameworks) == 0:
335 print(f"Warning: Could not find any external frameworks to deploy in {applicationBundle.path}.")
336 return DeploymentInfo()
337 else:
338 return deployFrameworks(frameworks, applicationBundle.path, applicationBundle.binaryPath, strip, verbose)
339
340 def deployPlugins(appBundleInfo: ApplicationBundleInfo, deploymentInfo: DeploymentInfo, strip: bool, verbose: int):
341 plugins = []
342 if deploymentInfo.pluginPath is None:
343 return
344 for dirpath, dirnames, filenames in os.walk(deploymentInfo.pluginPath):
345 pluginDirectory = os.path.relpath(dirpath, deploymentInfo.pluginPath)
346
347 if pluginDirectory not in ['styles', 'platforms']:
348 continue
349
350 for pluginName in filenames:
351 pluginPath = os.path.join(pluginDirectory, pluginName)
352
353 if pluginName.split('.')[0] not in ['libqminimal', 'libqcocoa', 'libqmacstyle']:
354 continue
355
356 plugins.append((pluginDirectory, pluginName))
357
358 for pluginDirectory, pluginName in plugins:
359 print("Processing plugin", os.path.join(pluginDirectory, pluginName), "...")
360
361 sourcePath = os.path.join(deploymentInfo.pluginPath, pluginDirectory, pluginName)
362 destinationDirectory = os.path.join(appBundleInfo.pluginPath, pluginDirectory)
363 if not os.path.exists(destinationDirectory):
364 os.makedirs(destinationDirectory)
365
366 destinationPath = os.path.join(destinationDirectory, pluginName)
367 shutil.copy2(sourcePath, destinationPath)
368 if verbose:
369 print("Copied:", sourcePath)
370 print(" to:", destinationPath)
371
372 if strip:
373 runStrip(destinationPath, verbose)
374
375 dependencies = getFrameworks(destinationPath, verbose)
376
377 for dependency in dependencies:
378 changeInstallName(dependency.installName, dependency.deployedInstallName, destinationPath, verbose)
379
380 # Deploy framework if necessary.
381 if dependency.frameworkName not in deploymentInfo.deployedFrameworks:
382 deployFrameworks([dependency], appBundleInfo.path, destinationPath, strip, verbose, deploymentInfo)
383
384 ap = ArgumentParser(description="""Improved version of macdeployqt.
385
386 Outputs a ready-to-deploy app in a folder "dist" and optionally wraps it in a .zip file.
387 Note, that the "dist" folder will be deleted before deploying on each run.
388
389 Optionally, Qt translation files (.qm) can be added to the bundle.""")
390
391 ap.add_argument("app_bundle", nargs=1, metavar="app-bundle", help="application bundle to be deployed")
392 ap.add_argument("appname", nargs=1, metavar="appname", help="name of the app being 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="?", const="", 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 appname = config.appname[0]
407
408 if not os.path.exists(app_bundle):
409 sys.stderr.write(f"Error: Could not find app bundle \"{app_bundle}\"\n")
410 sys.exit(1)
411
412 # ------------------------------------------------
413
414 if os.path.exists("dist"):
415 print("+ Removing existing dist folder +")
416 shutil.rmtree("dist")
417
418 if os.path.exists(appname + ".zip"):
419 print("+ Removing existing .zip +")
420 os.unlink(appname + ".zip")
421
422 # ------------------------------------------------
423
424 target = os.path.join("dist", "Limenka-Qt.app")
425
426 print("+ Copying source bundle +")
427 if verbose:
428 print(app_bundle, "->", target)
429
430 os.mkdir("dist")
431 shutil.copytree(app_bundle, target, symlinks=True)
432
433 applicationBundle = ApplicationBundleInfo(target)
434
435 # ------------------------------------------------
436
437 print("+ Deploying frameworks +")
438
439 try:
440 deploymentInfo = deployFrameworksForAppBundle(applicationBundle, config.strip, verbose)
441 if deploymentInfo.qtPath is None:
442 deploymentInfo.qtPath = os.getenv("QTDIR", None)
443 if deploymentInfo.qtPath is None:
444 sys.stderr.write("Warning: Could not detect Qt's path, skipping plugin deployment!\n")
445 config.plugins = False
446 except RuntimeError as e:
447 sys.stderr.write(f"Error: {str(e)}\n")
448 sys.exit(1)
449
450 # ------------------------------------------------
451
452 if config.plugins:
453 print("+ Deploying plugins +")
454
455 try:
456 deployPlugins(applicationBundle, deploymentInfo, config.strip, verbose)
457 except RuntimeError as e:
458 sys.stderr.write(f"Error: {str(e)}\n")
459 sys.exit(1)
460
461 # ------------------------------------------------
462
463 if config.translations_dir:
464 if not Path(config.translations_dir[0]).exists():
465 sys.stderr.write(f"Error: Could not find translation dir \"{config.translations_dir[0]}\"\n")
466 sys.exit(1)
467
468 print("+ Adding Qt translations +")
469
470 translations = Path(config.translations_dir[0])
471
472 regex = re.compile('qt_[a-z]*(.qm|_[A-Z]*.qm)')
473
474 lang_files = [x for x in translations.iterdir() if regex.match(x.name)]
475
476 for file in lang_files:
477 if verbose:
478 print(file.as_posix(), "->", os.path.join(applicationBundle.resourcesPath, file.name))
479 shutil.copy2(file.as_posix(), os.path.join(applicationBundle.resourcesPath, file.name))
480
481 # ------------------------------------------------
482
483 print("+ Installing qt.conf +")
484
485 qt_conf="""[Paths]
486 Translations=Resources
487 Plugins=PlugIns
488 """
489
490 with open(os.path.join(applicationBundle.resourcesPath, "qt.conf"), "wb") as f:
491 f.write(qt_conf.encode())
492
493 # ------------------------------------------------
494
495 if platform.system() == "Darwin":
496 subprocess.check_call(f"codesign --deep --force --sign - {target}", shell=True)
497
498 # ------------------------------------------------
499
500 if config.zip is not None:
501 shutil.make_archive('{}'.format(appname), format='zip', root_dir='dist', base_dir='Limenka-Qt.app')
502
503 # ------------------------------------------------
504
505 print("+ Done +")
506
507 sys.exit(0)
508