1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net> 4 5from __future__ import ( 6 absolute_import, division, print_function, unicode_literals 7) 8 9import atexit 10import json 11import os 12import platform 13import re 14import shlex 15import shutil 16import subprocess 17import sys 18import tempfile 19 20py3 = sys.version_info[0] > 2 21is64bit = platform.architecture()[0] == '64bit' 22is_macos = 'darwin' in sys.platform.lower() 23if is_macos: 24 mac_ver = tuple(map(int, platform.mac_ver()[0].split('.'))) 25 if mac_ver[:2] < (10, 12): 26 raise SystemExit('Your version of macOS is too old, at least 10.12 is required') 27 28try: 29 __file__ 30 from_file = True 31except NameError: 32 from_file = False 33 34if py3: 35 unicode = str 36 raw_input = input 37 import urllib.request as urllib 38 39 def encode_for_subprocess(x): 40 return x 41else: 42 from future_builtins import map 43 import urllib2 as urllib 44 45 def encode_for_subprocess(x): 46 if isinstance(x, unicode): 47 x = x.encode('utf-8') 48 return x 49 50 51def run(*args): 52 if len(args) == 1: 53 args = shlex.split(args[0]) 54 args = list(map(encode_for_subprocess, args)) 55 ret = subprocess.Popen(args).wait() 56 if ret != 0: 57 raise SystemExit(ret) 58 59 60class Reporter: # {{{ 61 62 def __init__(self, fname): 63 self.fname = fname 64 self.last_percent = 0 65 66 def __call__(self, blocks, block_size, total_size): 67 percent = (blocks*block_size)/float(total_size) 68 report = '\rDownloaded {:.1%} '.format(percent) 69 if percent - self.last_percent > 0.05: 70 self.last_percent = percent 71 print(report, end='') 72 sys.stdout.flush() 73# }}} 74 75 76def get_latest_release_data(): 77 print('Checking for latest release on GitHub...') 78 req = urllib.Request('https://api.github.com/repos/kovidgoyal/kitty/releases/latest', headers={'Accept': 'application/vnd.github.v3+json'}) 79 try: 80 res = urllib.urlopen(req).read().decode('utf-8') 81 except Exception as err: 82 raise SystemExit('Failed to contact {} with error: {}'.format(req.get_full_url(), err)) 83 data = json.loads(res) 84 html_url = data['html_url'].replace('/tag/', '/download/').rstrip('/') 85 for asset in data.get('assets', ()): 86 name = asset['name'] 87 if is_macos: 88 if name.endswith('.dmg'): 89 return html_url + '/' + name, asset['size'] 90 else: 91 if name.endswith('.txz'): 92 if is64bit: 93 if name.endswith('-x86_64.txz'): 94 return html_url + '/' + name, asset['size'] 95 else: 96 if name.endswith('-i686.txz'): 97 return html_url + '/' + name, asset['size'] 98 raise SystemExit('Failed to find the installer package on github') 99 100 101def do_download(url, size, dest): 102 print('Will download and install', os.path.basename(dest)) 103 reporter = Reporter(os.path.basename(dest)) 104 105 # Get content length and check if range is supported 106 rq = urllib.urlopen(url) 107 headers = rq.info() 108 sent_size = int(headers['content-length']) 109 if sent_size != size: 110 raise SystemExit('Failed to download from {} Content-Length ({}) != {}'.format(url, sent_size, size)) 111 with open(dest, 'wb') as f: 112 while f.tell() < size: 113 raw = rq.read(8192) 114 if not raw: 115 break 116 f.write(raw) 117 reporter(f.tell(), 1, size) 118 rq.close() 119 if os.path.getsize(dest) < size: 120 raise SystemExit('Download failed, try again later') 121 print('\rDownloaded {} bytes'.format(os.path.getsize(dest))) 122 123 124def clean_cache(cache, fname): 125 for x in os.listdir(cache): 126 if fname not in x: 127 os.remove(os.path.join(cache, x)) 128 129 130def download_installer(url, size): 131 fname = url.rpartition('/')[-1] 132 tdir = tempfile.gettempdir() 133 cache = os.path.join(tdir, 'kitty-installer-cache') 134 if not os.path.exists(cache): 135 os.makedirs(cache) 136 clean_cache(cache, fname) 137 dest = os.path.join(cache, fname) 138 if os.path.exists(dest) and os.path.getsize(dest) == size: 139 print('Using previously downloaded', fname) 140 return dest 141 if os.path.exists(dest): 142 os.remove(dest) 143 do_download(url, size, dest) 144 return dest 145 146 147def macos_install(dmg, dest='/Applications', launch=True): 148 mp = tempfile.mkdtemp() 149 atexit.register(shutil.rmtree, mp) 150 run('hdiutil', 'attach', dmg, '-mountpoint', mp) 151 try: 152 os.chdir(mp) 153 app = 'kitty.app' 154 d = os.path.join(dest, app) 155 if os.path.exists(d): 156 shutil.rmtree(d) 157 dest = os.path.join(dest, app) 158 run('ditto', '-v', app, dest) 159 print('Successfully installed kitty into', dest) 160 if launch: 161 run('open', dest) 162 finally: 163 os.chdir('/') 164 run('hdiutil', 'detach', mp) 165 166 167def linux_install(installer, dest=os.path.expanduser('~/.local'), launch=True): 168 dest = os.path.join(dest, 'kitty.app') 169 if os.path.exists(dest): 170 shutil.rmtree(dest) 171 os.makedirs(dest) 172 print('Extracting tarball...') 173 run('tar', '-C', dest, '-xJof', installer) 174 print('kitty successfully installed to', dest) 175 kitty = os.path.join(dest, 'bin', 'kitty') 176 print('Use', kitty, 'to run kitty') 177 if launch: 178 run(kitty, '--detach') 179 180 181def main(dest=None, launch=True, installer=None): 182 if not dest: 183 if is_macos: 184 dest = '/Applications' 185 else: 186 dest = os.path.expanduser('~/.local') 187 machine = os.uname()[4] 188 if machine and machine.lower().startswith('arm'): 189 raise SystemExit( 190 'You are running on an ARM system. The kitty binaries are only' 191 ' available for x86 systems. You will have to build from' 192 ' source.') 193 if not installer: 194 url, size = get_latest_release_data() 195 installer = download_installer(url, size) 196 else: 197 installer = os.path.abspath(installer) 198 if not os.access(installer, os.R_OK): 199 raise SystemExit('Could not read from: {}'.format(installer)) 200 if is_macos: 201 macos_install(installer, dest=dest, launch=launch) 202 else: 203 linux_install(installer, dest=dest, launch=launch) 204 205 206def script_launch(): 207 # To test: python3 -c "import runpy; runpy.run_path('installer.py', run_name='script_launch')" 208 def path(x): 209 return os.path.expandvars(os.path.expanduser(x)) 210 211 def to_bool(x): 212 return x.lower() in {'y', 'yes', '1', 'true'} 213 214 type_map = {x: path for x in 'dest installer'.split()} 215 type_map['launch'] = to_bool 216 kwargs = {} 217 218 for arg in sys.argv[1:]: 219 if arg: 220 m = re.match('([a-z_]+)=(.+)', arg) 221 if m is None: 222 raise SystemExit('Unrecognized command line argument: ' + arg) 223 k = m.group(1) 224 if k not in type_map: 225 raise SystemExit('Unrecognized command line argument: ' + arg) 226 kwargs[k] = type_map[k](m.group(2)) 227 main(**kwargs) 228 229 230def update_intaller_wrapper(): 231 # To run: python3 -c "import runpy; runpy.run_path('installer.py', run_name='update_wrapper')" installer.sh 232 with open(__file__, 'rb') as f: 233 src = f.read().decode('utf-8') 234 wrapper = sys.argv[-1] 235 with open(wrapper, 'r+b') as f: 236 raw = f.read().decode('utf-8') 237 nraw = re.sub(r'^# HEREDOC_START.+^# HEREDOC_END', lambda m: '# HEREDOC_START\n{}\n# HEREDOC_END'.format(src), raw, flags=re.MULTILINE | re.DOTALL) 238 if 'update_intaller_wrapper()' not in nraw: 239 raise SystemExit('regex substitute of HEREDOC failed') 240 f.seek(0), f.truncate() 241 f.write(nraw.encode('utf-8')) 242 243 244if __name__ == '__main__' and from_file: 245 main() 246elif __name__ == 'update_wrapper': 247 update_intaller_wrapper() 248elif __name__ == 'script_launch': 249 script_launch() 250