1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
4
5import locale
6import os
7import shutil
8import sys
9from contextlib import contextmanager, suppress
10from typing import Dict, Generator, List, Optional, Sequence, Tuple
11
12from .borders import load_borders_program
13from .boss import Boss
14from .child import set_default_env
15from .cli import create_opts, parse_args
16from .cli_stub import CLIOptions
17from .conf.utils import BadLine
18from .config import cached_values_for
19from .constants import (
20    appname, beam_cursor_data_file, config_dir, glfw_path, is_macos,
21    is_wayland, kitty_exe, logo_png_file, running_in_kitty
22)
23from .fast_data_types import (
24    GLFW_IBEAM_CURSOR, GLFW_MOD_ALT, GLFW_MOD_SHIFT, create_os_window,
25    free_font_data, glfw_init, glfw_terminate, load_png_data,
26    set_custom_cursor, set_default_window_icon, set_options
27)
28from .fonts.box_drawing import set_scale
29from .fonts.render import set_font_family
30from .options.types import Options
31from .os_window_size import initial_window_size_func
32from .session import get_os_window_sizing_data
33from .types import SingleKey
34from .utils import (
35    detach, expandvars, log_error, single_instance,
36    startup_notification_handler, unix_socket_paths
37)
38from .window import load_shader_programs
39
40
41def set_custom_ibeam_cursor() -> None:
42    with open(beam_cursor_data_file, 'rb') as f:
43        data = f.read()
44    rgba_data, width, height = load_png_data(data)
45    c2x = os.path.splitext(beam_cursor_data_file)
46    with open(c2x[0] + '@2x' + c2x[1], 'rb') as f:
47        data = f.read()
48    rgba_data2, width2, height2 = load_png_data(data)
49    images = (rgba_data, width, height), (rgba_data2, width2, height2)
50    try:
51        set_custom_cursor(GLFW_IBEAM_CURSOR, images, 4, 8)
52    except Exception as e:
53        log_error('Failed to set custom beam cursor with error: {}'.format(e))
54
55
56def talk_to_instance(args: CLIOptions) -> None:
57    import json
58    import socket
59    data = {'cmd': 'new_instance', 'args': tuple(sys.argv),
60            'startup_id': os.environ.get('DESKTOP_STARTUP_ID'),
61            'cwd': os.getcwd()}
62    notify_socket = None
63    if args.wait_for_single_instance_window_close:
64        address = '\0{}-os-window-close-notify-{}-{}'.format(appname, os.getpid(), os.geteuid())
65        notify_socket = socket.socket(family=socket.AF_UNIX)
66        try:
67            notify_socket.bind(address)
68        except FileNotFoundError:
69            for address in unix_socket_paths(address[1:], ext='.sock'):
70                notify_socket.bind(address)
71                break
72        data['notify_on_os_window_death'] = address
73        notify_socket.listen()
74
75    sdata = json.dumps(data, ensure_ascii=False).encode('utf-8')
76    assert single_instance.socket is not None
77    single_instance.socket.sendall(sdata)
78    with suppress(OSError):
79        single_instance.socket.shutdown(socket.SHUT_RDWR)
80    single_instance.socket.close()
81
82    if args.wait_for_single_instance_window_close:
83        assert notify_socket is not None
84        conn = notify_socket.accept()[0]
85        conn.recv(1)
86        with suppress(OSError):
87            conn.shutdown(socket.SHUT_RDWR)
88        conn.close()
89
90
91def load_all_shaders(semi_transparent: bool = False) -> None:
92    load_shader_programs(semi_transparent)
93    load_borders_program()
94
95
96def init_glfw_module(glfw_module: str, debug_keyboard: bool = False, debug_rendering: bool = False) -> None:
97    if not glfw_init(glfw_path(glfw_module), debug_keyboard, debug_rendering):
98        raise SystemExit('GLFW initialization failed')
99
100
101def init_glfw(opts: Options, debug_keyboard: bool = False, debug_rendering: bool = False) -> str:
102    glfw_module = 'cocoa' if is_macos else ('wayland' if is_wayland(opts) else 'x11')
103    init_glfw_module(glfw_module, debug_keyboard, debug_rendering)
104    return glfw_module
105
106
107def get_macos_shortcut_for(opts: Options, function: str = 'new_os_window', args: Tuple = (), lookup_name: str = '') -> Optional[SingleKey]:
108    ans = None
109    candidates = []
110    for k, v in opts.keymap.items():
111        if v.func == function and v.args == args:
112            candidates.append(k)
113    if candidates:
114        from .fast_data_types import cocoa_set_global_shortcut
115        alt_mods = GLFW_MOD_ALT, GLFW_MOD_ALT | GLFW_MOD_SHIFT
116        # Reverse list so that later defined keyboard shortcuts take priority over earlier defined ones
117        for candidate in reversed(candidates):
118            if candidate.mods in alt_mods:
119                # Option based shortcuts dont work in the global menubar,
120                # presumably because Apple reserves them for IME, see
121                # https://github.com/kovidgoyal/kitty/issues/3515
122                continue
123            if cocoa_set_global_shortcut(lookup_name or function, candidate[0], candidate[2]):
124                ans = candidate
125                break
126    return ans
127
128
129def set_x11_window_icon() -> None:
130    # max icon size on X11 64bits is 128x128
131    path, ext = os.path.splitext(logo_png_file)
132    set_default_window_icon(path + '-128' + ext)
133
134
135def _run_app(opts: Options, args: CLIOptions, bad_lines: Sequence[BadLine] = ()) -> None:
136    global_shortcuts: Dict[str, SingleKey] = {}
137    if is_macos:
138        for ac in ('new_os_window', 'close_os_window', 'close_tab', 'edit_config_file', 'previous_tab',
139                   'next_tab', 'new_tab', 'new_window', 'close_window'):
140            val = get_macos_shortcut_for(opts, ac)
141            if val is not None:
142                global_shortcuts[ac] = val
143        val = get_macos_shortcut_for(opts, 'clear_terminal', args=('reset', True), lookup_name='reset_terminal')
144        if val is not None:
145            global_shortcuts['reset_terminal'] = val
146        val = get_macos_shortcut_for(opts, 'load_config_file', args=(), lookup_name='reload_config')
147        if val is not None:
148            global_shortcuts['reload_config'] = val
149    if is_macos and opts.macos_custom_beam_cursor:
150        set_custom_ibeam_cursor()
151    if not is_wayland() and not is_macos:  # no window icons on wayland
152        set_x11_window_icon()
153    load_shader_programs.use_selection_fg = opts.selection_foreground is not None
154    with cached_values_for(run_app.cached_values_name) as cached_values:
155        with startup_notification_handler(extra_callback=run_app.first_window_callback) as pre_show_callback:
156            window_id = create_os_window(
157                    run_app.initial_window_size_func(get_os_window_sizing_data(opts), cached_values),
158                    pre_show_callback,
159                    args.title or appname, args.name or args.cls or appname,
160                    args.cls or appname, load_all_shaders, disallow_override_title=bool(args.title))
161        boss = Boss(opts, args, cached_values, global_shortcuts)
162        boss.start(window_id)
163        if bad_lines:
164            boss.show_bad_config_lines(bad_lines)
165        try:
166            boss.child_monitor.main_loop()
167        finally:
168            boss.destroy()
169
170
171class AppRunner:
172
173    def __init__(self) -> None:
174        self.cached_values_name = 'main'
175        self.first_window_callback = lambda window_handle: None
176        self.initial_window_size_func = initial_window_size_func
177
178    def __call__(self, opts: Options, args: CLIOptions, bad_lines: Sequence[BadLine] = ()) -> None:
179        set_scale(opts.box_drawing_scale)
180        set_options(opts, is_wayland(), args.debug_rendering, args.debug_font_fallback)
181        try:
182            set_font_family(opts, debug_font_matching=args.debug_font_fallback)
183            _run_app(opts, args, bad_lines)
184        finally:
185            set_options(None)
186            free_font_data()  # must free font data before glfw/freetype/fontconfig/opengl etc are finalized
187            if is_macos:
188                from kitty.fast_data_types import cocoa_set_notification_activated_callback
189                cocoa_set_notification_activated_callback(None)
190
191
192run_app = AppRunner()
193
194
195def ensure_macos_locale() -> None:
196    # Ensure the LANG env var is set. See
197    # https://github.com/kovidgoyal/kitty/issues/90
198    from .fast_data_types import cocoa_get_lang, locale_is_valid
199    if 'LANG' not in os.environ:
200        lang = cocoa_get_lang()
201        if lang is not None:
202            if not locale_is_valid(lang):
203                if lang.startswith('en_'):
204                    lang = 'en_US'
205                else:
206                    log_error(f'Could not set LANG Cocoa returns language as: {lang}')
207            os.environ['LANG'] = lang + '.UTF-8'
208
209
210@contextmanager
211def setup_profiling(args: CLIOptions) -> Generator[None, None, None]:
212    try:
213        from .fast_data_types import start_profiler, stop_profiler
214        do_profile = True
215    except ImportError:
216        do_profile = False
217    if do_profile:
218        start_profiler('/tmp/kitty-profile.log')
219    yield
220    if do_profile:
221        import subprocess
222        stop_profiler()
223        exe = kitty_exe()
224        cg = '/tmp/kitty-profile.callgrind'
225        print('Post processing profile data for', exe, '...')
226        with open(cg, 'wb') as f:
227            subprocess.call(['pprof', '--callgrind', exe, '/tmp/kitty-profile.log'], stdout=f)
228        try:
229            subprocess.Popen(['kcachegrind', cg])
230        except FileNotFoundError:
231            subprocess.call(['pprof', '--text', exe, '/tmp/kitty-profile.log'])
232            print('To view the graphical call data, use: kcachegrind', cg)
233
234
235def macos_cmdline(argv_args: List[str]) -> List[str]:
236    try:
237        with open(os.path.join(config_dir, 'macos-launch-services-cmdline')) as f:
238            raw = f.read()
239    except FileNotFoundError:
240        return argv_args
241    import shlex
242    raw = raw.strip()
243    ans = shlex.split(raw)
244    if ans and ans[0] == 'kitty':
245        del ans[0]
246    return ans
247
248
249def expand_listen_on(listen_on: str, from_config_file: bool) -> str:
250    listen_on = expandvars(listen_on)
251    if '{kitty_pid}' not in listen_on and from_config_file:
252        listen_on += '-{kitty_pid}'
253    listen_on = listen_on.replace('{kitty_pid}', str(os.getpid()))
254    if listen_on.startswith('unix:'):
255        path = listen_on[len('unix:'):]
256        if not path.startswith('@'):
257            if path.startswith('~'):
258                listen_on = f'unix:{os.path.expanduser(path)}'
259            elif not os.path.isabs(path):
260                import tempfile
261                listen_on = f'unix:{os.path.join(tempfile.gettempdir(), path)}'
262    return listen_on
263
264
265def setup_environment(opts: Options, cli_opts: CLIOptions) -> None:
266    from_config_file = False
267    if not cli_opts.listen_on and opts.listen_on.startswith('unix:'):
268        cli_opts.listen_on = opts.listen_on
269        from_config_file = True
270    if cli_opts.listen_on and opts.allow_remote_control != 'n':
271        cli_opts.listen_on = expand_listen_on(cli_opts.listen_on, from_config_file)
272        os.environ['KITTY_LISTEN_ON'] = cli_opts.listen_on
273    set_default_env(opts.env.copy())
274
275
276def set_locale() -> None:
277    if is_macos:
278        ensure_macos_locale()
279    try:
280        locale.setlocale(locale.LC_ALL, '')
281    except Exception:
282        log_error('Failed to set locale with LANG:', os.environ.get('LANG'))
283        os.environ.pop('LANG', None)
284        try:
285            locale.setlocale(locale.LC_ALL, '')
286        except Exception:
287            log_error('Failed to set locale with no LANG')
288
289
290def _main() -> None:
291    running_in_kitty(True)
292    with suppress(AttributeError):  # python compiled without threading
293        sys.setswitchinterval(1000.0)  # we have only a single python thread
294
295    try:
296        set_locale()
297    except Exception:
298        log_error('Failed to set locale, ignoring')
299
300    # Ensure the correct kitty is in PATH
301    rpath = sys._xoptions.get('bundle_exe_dir')
302    if rpath:
303        modify_path = is_macos or getattr(sys, 'frozen', False) or sys._xoptions.get('kitty_from_source') == '1'
304        if modify_path or not shutil.which('kitty'):
305            existing_paths = list(filter(None, os.environ.get('PATH', '').split(os.pathsep)))
306            existing_paths.insert(0, rpath)
307            os.environ['PATH'] = os.pathsep.join(existing_paths)
308
309    args = sys.argv[1:]
310    if is_macos and os.environ.pop('KITTY_LAUNCHED_BY_LAUNCH_SERVICES', None) == '1':
311        os.chdir(os.path.expanduser('~'))
312        args = macos_cmdline(args)
313    try:
314        cwd_ok = os.path.isdir(os.getcwd())
315    except Exception:
316        cwd_ok = False
317    if not cwd_ok:
318        os.chdir(os.path.expanduser('~'))
319    cli_opts, rest = parse_args(args=args, result_class=CLIOptions)
320    cli_opts.args = rest
321    if cli_opts.detach:
322        if cli_opts.session == '-':
323            from .session import PreReadSession
324            cli_opts.session = PreReadSession(sys.stdin.read())
325        detach()
326    if cli_opts.replay_commands:
327        from kitty.client import main as client_main
328        client_main(cli_opts.replay_commands)
329        return
330    if cli_opts.single_instance:
331        is_first = single_instance(cli_opts.instance_group)
332        if not is_first:
333            talk_to_instance(cli_opts)
334            return
335    bad_lines: List[BadLine] = []
336    opts = create_opts(cli_opts, accumulate_bad_lines=bad_lines)
337    init_glfw(opts, cli_opts.debug_keyboard, cli_opts.debug_rendering)
338    setup_environment(opts, cli_opts)
339    try:
340        with setup_profiling(cli_opts):
341            # Avoid needing to launch threads to reap zombies
342            run_app(opts, cli_opts, bad_lines)
343    finally:
344        glfw_terminate()
345
346
347def main() -> None:
348    try:
349        _main()
350    except Exception:
351        import traceback
352        tb = traceback.format_exc()
353        log_error(tb)
354        raise SystemExit(1)
355