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