__main__.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. #!/usr/bin/env python
  2. # vim:fileencoding=utf-8
  3. # License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
  4. import glob
  5. import json
  6. import os
  7. import shutil
  8. import stat
  9. import subprocess
  10. import sys
  11. import tempfile
  12. import zipfile
  13. from bypy.constants import PREFIX, PYTHON, SW, python_major_minor_version
  14. from bypy.freeze import extract_extension_modules, freeze_python, path_to_freeze_dir
  15. from bypy.macos_sign import codesign, create_entitlements_file, make_certificate_useable, notarize_app, verify_signature
  16. from bypy.utils import current_dir, mkdtemp, py_compile, run_shell, timeit, walk
  17. iv = globals()['init_env']
  18. kitty_constants = iv['kitty_constants']
  19. self_dir = os.path.dirname(os.path.abspath(__file__))
  20. join = os.path.join
  21. basename = os.path.basename
  22. dirname = os.path.dirname
  23. abspath = os.path.abspath
  24. APPNAME = kitty_constants['appname']
  25. VERSION = kitty_constants['version']
  26. py_ver = '.'.join(map(str, python_major_minor_version()))
  27. def flush(func):
  28. def ff(*args, **kwargs):
  29. sys.stdout.flush()
  30. sys.stderr.flush()
  31. ret = func(*args, **kwargs)
  32. sys.stdout.flush()
  33. sys.stderr.flush()
  34. return ret
  35. return ff
  36. def flipwritable(fn, mode=None):
  37. """
  38. Flip the writability of a file and return the old mode. Returns None
  39. if the file is already writable.
  40. """
  41. if os.access(fn, os.W_OK):
  42. return None
  43. old_mode = os.stat(fn).st_mode
  44. os.chmod(fn, stat.S_IWRITE | old_mode)
  45. return old_mode
  46. STRIPCMD = ('/usr/bin/strip', '-x', '-S', '-')
  47. def strip_files(files, argv_max=(256 * 1024)):
  48. """
  49. Strip a list of files
  50. """
  51. tostrip = [(fn, flipwritable(fn)) for fn in files if os.path.exists(fn)]
  52. while tostrip:
  53. cmd = list(STRIPCMD)
  54. flips = []
  55. pathlen = sum(len(s) + 1 for s in cmd)
  56. while pathlen < argv_max:
  57. if not tostrip:
  58. break
  59. added, flip = tostrip.pop()
  60. pathlen += len(added) + 1
  61. cmd.append(added)
  62. flips.append((added, flip))
  63. else:
  64. cmd.pop()
  65. tostrip.append(flips.pop())
  66. os.spawnv(os.P_WAIT, cmd[0], cmd)
  67. for args in flips:
  68. flipwritable(*args)
  69. def files_in(folder):
  70. for record in os.walk(folder):
  71. for f in record[-1]:
  72. yield join(record[0], f)
  73. def expand_dirs(items, exclude=lambda x: x.endswith('.so')):
  74. items = set(items)
  75. dirs = set(x for x in items if os.path.isdir(x))
  76. items.difference_update(dirs)
  77. for x in dirs:
  78. items.update({y for y in files_in(x) if not exclude(y)})
  79. return items
  80. def do_sign(app_dir):
  81. with current_dir(join(app_dir, 'Contents')):
  82. # Sign all .so files
  83. so_files = {x for x in files_in('.') if x.endswith('.so')}
  84. codesign(so_files)
  85. # Sign everything else in Frameworks
  86. with current_dir('Frameworks'):
  87. fw = set(glob.glob('*.framework'))
  88. codesign(fw)
  89. items = set(os.listdir('.')) - fw
  90. codesign(expand_dirs(items))
  91. # Sign kitten
  92. with current_dir('MacOS'):
  93. codesign('kitten')
  94. # Now sign the main app
  95. codesign(app_dir)
  96. verify_signature(app_dir)
  97. def sign_app(app_dir, notarize):
  98. # Copied from iTerm2: https://github.com/gnachman/iTerm2/blob/master/iTerm2.entitlements
  99. create_entitlements_file({
  100. 'com.apple.security.automation.apple-events': True,
  101. 'com.apple.security.cs.allow-jit': True,
  102. 'com.apple.security.device.audio-input': True,
  103. 'com.apple.security.device.camera': True,
  104. 'com.apple.security.personal-information.addressbook': True,
  105. 'com.apple.security.personal-information.calendars': True,
  106. 'com.apple.security.personal-information.location': True,
  107. 'com.apple.security.personal-information.photos-library': True,
  108. })
  109. with make_certificate_useable():
  110. do_sign(app_dir)
  111. if notarize:
  112. notarize_app(app_dir)
  113. class Freeze(object):
  114. FID = '@executable_path/../Frameworks'
  115. def __init__(self, build_dir, dont_strip=False, sign_installers=False, notarize=False, skip_tests=False):
  116. self.build_dir = build_dir
  117. self.skip_tests = skip_tests
  118. self.sign_installers = sign_installers
  119. self.notarize = notarize
  120. self.dont_strip = dont_strip
  121. self.contents_dir = join(self.build_dir, 'Contents')
  122. self.resources_dir = join(self.contents_dir, 'Resources')
  123. self.frameworks_dir = join(self.contents_dir, 'Frameworks')
  124. self.to_strip = []
  125. self.warnings = []
  126. self.py_ver = py_ver
  127. self.python_stdlib = join(self.resources_dir, 'Python', 'lib', f'python{self.py_ver}')
  128. self.site_packages = self.python_stdlib # hack to avoid needing to add site-packages to path
  129. self.obj_dir = mkdtemp('launchers-')
  130. self.run()
  131. def run_shell(self):
  132. with current_dir(self.contents_dir):
  133. run_shell()
  134. def run(self):
  135. ret = 0
  136. self.add_python_framework()
  137. self.add_site_packages()
  138. self.add_stdlib()
  139. self.add_misc_libraries()
  140. self.freeze_python()
  141. self.add_ca_certs()
  142. self.build_frozen_tools()
  143. if not self.dont_strip:
  144. self.strip_files()
  145. if not self.skip_tests:
  146. self.run_tests()
  147. # self.run_shell()
  148. ret = self.makedmg(self.build_dir, f'{APPNAME}-{VERSION}')
  149. return ret
  150. @flush
  151. def add_ca_certs(self):
  152. print('\nDownloading CA certs...')
  153. from urllib.request import urlopen
  154. cdata = None
  155. for i in range(5):
  156. try:
  157. cdata = urlopen(kitty_constants['cacerts_url']).read()
  158. break
  159. except Exception as e:
  160. print(f'Downloading CA certs failed with error: {e}, retrying...')
  161. if cdata is None:
  162. raise SystemExit('Downloading C certs failed, giving up')
  163. dest = join(self.contents_dir, 'Resources', 'cacert.pem')
  164. with open(dest, 'wb') as f:
  165. f.write(cdata)
  166. @flush
  167. def strip_files(self):
  168. print('\nStripping files...')
  169. strip_files(self.to_strip)
  170. @flush
  171. def run_tests(self):
  172. iv['run_tests'](join(self.contents_dir, 'MacOS', 'kitty'))
  173. @flush
  174. def set_id(self, path_to_lib, new_id):
  175. old_mode = flipwritable(path_to_lib)
  176. subprocess.check_call(
  177. ['install_name_tool', '-id', new_id, path_to_lib])
  178. if old_mode is not None:
  179. flipwritable(path_to_lib, old_mode)
  180. @flush
  181. def get_dependencies(self, path_to_lib):
  182. install_name = subprocess.check_output(
  183. ['otool', '-D', path_to_lib]).decode('utf-8').splitlines()[-1].strip()
  184. raw = subprocess.check_output(['otool', '-L', path_to_lib]).decode('utf-8')
  185. for line in raw.splitlines():
  186. if 'compatibility' not in line or line.strip().endswith(':'):
  187. continue
  188. idx = line.find('(')
  189. path = line[:idx].strip()
  190. yield path, path == install_name
  191. @flush
  192. def get_local_dependencies(self, path_to_lib):
  193. for x, is_id in self.get_dependencies(path_to_lib):
  194. for y in (f'{PREFIX}/lib/', f'{PREFIX}/python/Python.framework/', '@rpath/'):
  195. if x.startswith(y):
  196. if y == f'{PREFIX}/python/Python.framework/':
  197. y = f'{PREFIX}/python/'
  198. yield x, x[len(y):], is_id
  199. break
  200. @flush
  201. def change_dep(self, old_dep, new_dep, is_id, path_to_lib):
  202. cmd = ['-id', new_dep] if is_id else ['-change', old_dep, new_dep]
  203. subprocess.check_call(['install_name_tool'] + cmd + [path_to_lib])
  204. @flush
  205. def fix_dependencies_in_lib(self, path_to_lib):
  206. self.to_strip.append(path_to_lib)
  207. old_mode = flipwritable(path_to_lib)
  208. for dep, bname, is_id in self.get_local_dependencies(path_to_lib):
  209. ndep = f'{self.FID}/{bname}'
  210. self.change_dep(dep, ndep, is_id, path_to_lib)
  211. ldeps = list(self.get_local_dependencies(path_to_lib))
  212. if ldeps:
  213. print('\nFailed to fix dependencies in', path_to_lib)
  214. print('Remaining local dependencies:', ldeps)
  215. raise SystemExit(1)
  216. if old_mode is not None:
  217. flipwritable(path_to_lib, old_mode)
  218. @flush
  219. def add_python_framework(self):
  220. print('\nAdding Python framework')
  221. src = join(f'{PREFIX}/python', 'Python.framework')
  222. x = join(self.frameworks_dir, 'Python.framework')
  223. curr = os.path.realpath(join(src, 'Versions', 'Current'))
  224. currd = join(x, 'Versions', basename(curr))
  225. rd = join(currd, 'Resources')
  226. os.makedirs(rd)
  227. shutil.copy2(join(curr, 'Resources', 'Info.plist'), rd)
  228. shutil.copy2(join(curr, 'Python'), currd)
  229. self.set_id(
  230. join(currd, 'Python'),
  231. f'{self.FID}/Python.framework/Versions/{basename(curr)}/Python')
  232. # The following is needed for codesign
  233. with current_dir(x):
  234. os.symlink(basename(curr), 'Versions/Current')
  235. for y in ('Python', 'Resources'):
  236. os.symlink(f'Versions/Current/{y}', y)
  237. @flush
  238. def install_dylib(self, path, set_id=True):
  239. shutil.copy2(path, self.frameworks_dir)
  240. if set_id:
  241. self.set_id(
  242. join(self.frameworks_dir, basename(path)),
  243. f'{self.FID}/{basename(path)}')
  244. self.fix_dependencies_in_lib(join(self.frameworks_dir, basename(path)))
  245. @flush
  246. def add_misc_libraries(self):
  247. for x in (
  248. 'sqlite3.0',
  249. 'z.1',
  250. 'harfbuzz.0',
  251. 'png16.16',
  252. 'lcms2.2',
  253. 'crypto.3',
  254. 'ssl.3',
  255. 'xxhash.0',
  256. ):
  257. print('\nAdding', x)
  258. x = f'lib{x}.dylib'
  259. src = join(PREFIX, 'lib', x)
  260. shutil.copy2(src, self.frameworks_dir)
  261. dest = join(self.frameworks_dir, x)
  262. self.set_id(dest, f'{self.FID}/{x}')
  263. self.fix_dependencies_in_lib(dest)
  264. @flush
  265. def add_package_dir(self, x, dest=None):
  266. def ignore(root, files):
  267. ans = []
  268. for y in files:
  269. ext = os.path.splitext(y)[1]
  270. if ext not in ('', '.py', '.so') or \
  271. (not ext and not os.path.isdir(join(root, y))):
  272. ans.append(y)
  273. return ans
  274. if dest is None:
  275. dest = self.site_packages
  276. dest = join(dest, basename(x))
  277. shutil.copytree(x, dest, symlinks=True, ignore=ignore)
  278. for f in walk(dest):
  279. if f.endswith('.so'):
  280. self.fix_dependencies_in_lib(f)
  281. @flush
  282. def add_stdlib(self):
  283. print('\nAdding python stdlib')
  284. src = f'{PREFIX}/python/Python.framework/Versions/Current/lib/python{self.py_ver}'
  285. dest = self.python_stdlib
  286. if not os.path.exists(dest):
  287. os.makedirs(dest)
  288. for x in os.listdir(src):
  289. if x in ('site-packages', 'config', 'test', 'lib2to3', 'lib-tk',
  290. 'lib-old', 'idlelib', 'plat-mac', 'plat-darwin',
  291. 'site.py', 'distutils', 'turtledemo', 'tkinter'):
  292. continue
  293. x = join(src, x)
  294. if os.path.isdir(x):
  295. self.add_package_dir(x, dest)
  296. elif os.path.splitext(x)[1] in ('.so', '.py'):
  297. shutil.copy2(x, dest)
  298. dest2 = join(dest, basename(x))
  299. if dest2.endswith('.so'):
  300. self.fix_dependencies_in_lib(dest2)
  301. @flush
  302. def freeze_python(self):
  303. print('\nFreezing python')
  304. kitty_dir = join(self.resources_dir, 'kitty')
  305. bases = ('kitty', 'kittens', 'kitty_tests')
  306. for x in bases:
  307. dest = join(self.python_stdlib, x)
  308. os.rename(join(kitty_dir, x), dest)
  309. if x == 'kitty':
  310. shutil.rmtree(join(dest, 'launcher'))
  311. os.rename(join(kitty_dir, '__main__.py'), join(self.python_stdlib, 'kitty_main.py'))
  312. shutil.rmtree(join(kitty_dir, '__pycache__'))
  313. pdir = join(dirname(self.python_stdlib), 'kitty-extensions')
  314. os.mkdir(pdir)
  315. print('Extracting extension modules from', self.python_stdlib, 'to', pdir)
  316. ext_map = extract_extension_modules(self.python_stdlib, pdir)
  317. shutil.copy(join(os.path.dirname(self_dir), 'site.py'), join(self.python_stdlib, 'site.py'))
  318. for x in bases:
  319. iv['sanitize_source_folder'](join(self.python_stdlib, x))
  320. self.compile_py_modules()
  321. freeze_python(self.python_stdlib, pdir, self.obj_dir, ext_map, develop_mode_env_var='KITTY_DEVELOP_FROM', remove_pyc_files=True)
  322. shutil.rmtree(self.python_stdlib)
  323. iv['build_frozen_launcher']([path_to_freeze_dir(), self.obj_dir])
  324. os.rename(join(dirname(self.contents_dir), 'bin', 'kitty'), join(self.contents_dir, 'MacOS', 'kitty'))
  325. shutil.rmtree(join(dirname(self.contents_dir), 'bin'))
  326. self.fix_dependencies_in_lib(join(self.contents_dir, 'MacOS', 'kitty'))
  327. for f in walk(pdir):
  328. if f.endswith('.so') or f.endswith('.dylib'):
  329. self.fix_dependencies_in_lib(f)
  330. @flush
  331. def build_frozen_tools(self):
  332. iv['build_frozen_tools'](join(self.contents_dir, 'MacOS', 'kitty'))
  333. @flush
  334. def add_site_packages(self):
  335. print('\nAdding site-packages')
  336. os.makedirs(self.site_packages)
  337. sys_path = json.loads(subprocess.check_output([
  338. PYTHON, '-c', 'import sys, json; json.dump(sys.path, sys.stdout)']))
  339. paths = reversed(tuple(map(abspath, [x for x in sys_path if x.startswith('/') and not x.startswith('/Library/')])))
  340. upaths = []
  341. for x in paths:
  342. if x not in upaths and (x.endswith('.egg') or x.endswith('/site-packages')):
  343. upaths.append(x)
  344. for x in upaths:
  345. print('\t', x)
  346. tdir = None
  347. try:
  348. if not os.path.isdir(x):
  349. zf = zipfile.ZipFile(x)
  350. tdir = tempfile.mkdtemp()
  351. zf.extractall(tdir)
  352. x = tdir
  353. self.add_modules_from_dir(x)
  354. self.add_packages_from_dir(x)
  355. finally:
  356. if tdir is not None:
  357. shutil.rmtree(tdir)
  358. self.remove_bytecode(self.site_packages)
  359. @flush
  360. def add_modules_from_dir(self, src):
  361. for x in glob.glob(join(src, '*.py')) + glob.glob(join(src, '*.so')):
  362. shutil.copy2(x, self.site_packages)
  363. if x.endswith('.so'):
  364. self.fix_dependencies_in_lib(x)
  365. @flush
  366. def add_packages_from_dir(self, src):
  367. for x in os.listdir(src):
  368. x = join(src, x)
  369. if os.path.isdir(x) and os.path.exists(join(x, '__init__.py')):
  370. if self.filter_package(basename(x)):
  371. continue
  372. self.add_package_dir(x)
  373. @flush
  374. def filter_package(self, name):
  375. return name in ('Cython', 'modulegraph', 'macholib', 'py2app',
  376. 'bdist_mpkg', 'altgraph')
  377. @flush
  378. def remove_bytecode(self, dest):
  379. for x in os.walk(dest):
  380. root = x[0]
  381. for f in x[-1]:
  382. if os.path.splitext(f) == '.pyc':
  383. os.remove(join(root, f))
  384. @flush
  385. def compile_py_modules(self):
  386. self.remove_bytecode(join(self.resources_dir, 'Python'))
  387. py_compile(join(self.resources_dir, 'Python'))
  388. @flush
  389. def makedmg(self, d, volname, format='ULFO'):
  390. ''' Copy a directory d into a dmg named volname '''
  391. print('\nMaking dmg...')
  392. sys.stdout.flush()
  393. destdir = join(SW, 'dist')
  394. try:
  395. shutil.rmtree(destdir)
  396. except FileNotFoundError:
  397. pass
  398. os.mkdir(destdir)
  399. dmg = join(destdir, f'{volname}.dmg')
  400. if os.path.exists(dmg):
  401. os.unlink(dmg)
  402. tdir = tempfile.mkdtemp()
  403. appdir = join(tdir, os.path.basename(d))
  404. shutil.copytree(d, appdir, symlinks=True)
  405. if self.sign_installers:
  406. with timeit() as times:
  407. sign_app(appdir, self.notarize)
  408. print('Signing completed in {} minutes {} seconds'.format(*times))
  409. os.symlink('/Applications', join(tdir, 'Applications'))
  410. size_in_mb = int(
  411. subprocess.check_output(['du', '-s', '-k', tdir]).decode('utf-8')
  412. .split()[0]) / 1024.
  413. cmd = [
  414. '/usr/bin/hdiutil', 'create', '-srcfolder', tdir, '-volname',
  415. volname, '-format', format
  416. ]
  417. if 190 < size_in_mb < 250:
  418. # We need -size 255m because of a bug in hdiutil. When the size of
  419. # srcfolder is close to 200MB hdiutil fails with
  420. # diskimages-helper: resize request is above maximum size allowed.
  421. cmd += ['-size', '255m']
  422. print('\nCreating dmg...')
  423. with timeit() as times:
  424. subprocess.check_call(cmd + [dmg])
  425. print('dmg created in {} minutes and {} seconds'.format(*times))
  426. shutil.rmtree(tdir)
  427. size = os.stat(dmg).st_size / (1024 * 1024.)
  428. print(f'\nInstaller size: {size:.2f}MB\n')
  429. return dmg
  430. def main():
  431. args = globals()['args']
  432. ext_dir = globals()['ext_dir']
  433. Freeze(
  434. join(ext_dir, f'{kitty_constants["appname"]}.app'),
  435. dont_strip=args.dont_strip,
  436. sign_installers=args.sign_installers,
  437. notarize=args.notarize,
  438. skip_tests=args.skip_tests
  439. )
  440. if __name__ == '__main__':
  441. main()