xref: /openbmc/openbmc/poky/meta/lib/oe/terminal.py (revision e760df85)
1#
2# Copyright OpenEmbedded Contributors
3#
4# SPDX-License-Identifier: GPL-2.0-only
5#
6import logging
7import oe.classutils
8import shlex
9from bb.process import Popen, ExecutionError
10
11logger = logging.getLogger('BitBake.OE.Terminal')
12
13
14class UnsupportedTerminal(Exception):
15    pass
16
17class NoSupportedTerminals(Exception):
18    def __init__(self, terms):
19        self.terms = terms
20
21
22class Registry(oe.classutils.ClassRegistry):
23    command = None
24
25    def __init__(cls, name, bases, attrs):
26        super(Registry, cls).__init__(name.lower(), bases, attrs)
27
28    @property
29    def implemented(cls):
30        return bool(cls.command)
31
32
33class Terminal(Popen, metaclass=Registry):
34    def __init__(self, sh_cmd, title=None, env=None, d=None):
35        from subprocess import STDOUT
36        fmt_sh_cmd = self.format_command(sh_cmd, title)
37        try:
38            Popen.__init__(self, fmt_sh_cmd, env=env, stderr=STDOUT)
39        except OSError as exc:
40            import errno
41            if exc.errno == errno.ENOENT:
42                raise UnsupportedTerminal(self.name)
43            else:
44                raise
45
46    def format_command(self, sh_cmd, title):
47        fmt = {'title': title or 'Terminal', 'command': sh_cmd, 'cwd': os.getcwd() }
48        if isinstance(self.command, str):
49            return shlex.split(self.command.format(**fmt))
50        else:
51            return [element.format(**fmt) for element in self.command]
52
53class XTerminal(Terminal):
54    def __init__(self, sh_cmd, title=None, env=None, d=None):
55        Terminal.__init__(self, sh_cmd, title, env, d)
56        if not os.environ.get('DISPLAY'):
57            raise UnsupportedTerminal(self.name)
58
59class Gnome(XTerminal):
60    command = 'gnome-terminal -t "{title}" -- {command}'
61    priority = 2
62
63    def __init__(self, sh_cmd, title=None, env=None, d=None):
64        # Recent versions of gnome-terminal does not support non-UTF8 charset:
65        # https://bugzilla.gnome.org/show_bug.cgi?id=732127; as a workaround,
66        # clearing the LC_ALL environment variable so it uses the locale.
67        # Once fixed on the gnome-terminal project, this should be removed.
68        if os.getenv('LC_ALL'): os.putenv('LC_ALL','')
69
70        XTerminal.__init__(self, sh_cmd, title, env, d)
71
72class Mate(XTerminal):
73    command = 'mate-terminal --disable-factory -t "{title}" -x {command}'
74    priority = 2
75
76class Xfce(XTerminal):
77    command = 'xfce4-terminal -T "{title}" -e "{command}"'
78    priority = 2
79
80class Terminology(XTerminal):
81    command = 'terminology -T="{title}" -e {command}'
82    priority = 2
83
84class Konsole(XTerminal):
85    command = 'konsole --separate --workdir . -p tabtitle="{title}" -e {command}'
86    priority = 2
87
88    def __init__(self, sh_cmd, title=None, env=None, d=None):
89        # Check version
90        vernum = check_terminal_version("konsole")
91        if vernum and bb.utils.vercmp_string_op(vernum, "2.0.0", "<"):
92            # Konsole from KDE 3.x
93            self.command = 'konsole -T "{title}" -e {command}'
94        elif vernum and bb.utils.vercmp_string_op(vernum, "16.08.1", "<"):
95            # Konsole pre 16.08.01 Has nofork
96            self.command = 'konsole --nofork --workdir . -p tabtitle="{title}" -e {command}'
97        XTerminal.__init__(self, sh_cmd, title, env, d)
98
99class XTerm(XTerminal):
100    command = 'xterm -T "{title}" -e {command}'
101    priority = 1
102
103class Rxvt(XTerminal):
104    command = 'rxvt -T "{title}" -e {command}'
105    priority = 1
106
107class URxvt(XTerminal):
108    command = 'urxvt -T "{title}" -e {command}'
109    priority = 1
110
111class Screen(Terminal):
112    command = 'screen -D -m -t "{title}" -S devshell {command}'
113
114    def __init__(self, sh_cmd, title=None, env=None, d=None):
115        s_id = "devshell_%i" % os.getpid()
116        self.command = "screen -D -m -t \"{title}\" -S %s {command}" % s_id
117        Terminal.__init__(self, sh_cmd, title, env, d)
118        msg = 'Screen started. Please connect in another terminal with ' \
119            '"screen -r %s"' % s_id
120        if (d):
121            bb.event.fire(bb.event.LogExecTTY(msg, "screen -r %s" % s_id,
122                                              0.5, 10), d)
123        else:
124            logger.warning(msg)
125
126class TmuxRunning(Terminal):
127    """Open a new pane in the current running tmux window"""
128    name = 'tmux-running'
129    command = 'tmux split-window -c "{cwd}" "{command}"'
130    priority = 2.75
131
132    def __init__(self, sh_cmd, title=None, env=None, d=None):
133        if not bb.utils.which(os.getenv('PATH'), 'tmux'):
134            raise UnsupportedTerminal('tmux is not installed')
135
136        if not os.getenv('TMUX'):
137            raise UnsupportedTerminal('tmux is not running')
138
139        if not check_tmux_pane_size('tmux'):
140            raise UnsupportedTerminal('tmux pane too small or tmux < 1.9 version is being used')
141
142        Terminal.__init__(self, sh_cmd, title, env, d)
143
144class TmuxNewWindow(Terminal):
145    """Open a new window in the current running tmux session"""
146    name = 'tmux-new-window'
147    command = 'tmux new-window -c "{cwd}" -n "{title}" "{command}"'
148    priority = 2.70
149
150    def __init__(self, sh_cmd, title=None, env=None, d=None):
151        if not bb.utils.which(os.getenv('PATH'), 'tmux'):
152            raise UnsupportedTerminal('tmux is not installed')
153
154        if not os.getenv('TMUX'):
155            raise UnsupportedTerminal('tmux is not running')
156
157        Terminal.__init__(self, sh_cmd, title, env, d)
158
159class Tmux(Terminal):
160    """Start a new tmux session and window"""
161    command = 'tmux new -c "{cwd}" -d -s devshell -n devshell "{command}"'
162    priority = 0.75
163
164    def __init__(self, sh_cmd, title=None, env=None, d=None):
165        if not bb.utils.which(os.getenv('PATH'), 'tmux'):
166            raise UnsupportedTerminal('tmux is not installed')
167
168        # TODO: consider using a 'devshell' session shared amongst all
169        # devshells, if it's already there, add a new window to it.
170        window_name = 'devshell-%i' % os.getpid()
171
172        self.command = 'tmux new -c "{{cwd}}" -d -s {0} -n {0} "{{command}}"'
173        if not check_tmux_version('1.9'):
174            # `tmux new-session -c` was added in 1.9;
175            # older versions fail with that flag
176            self.command = 'tmux new -d -s {0} -n {0} "{{command}}"'
177        self.command = self.command.format(window_name)
178        Terminal.__init__(self, sh_cmd, title, env, d)
179
180        attach_cmd = 'tmux att -t {0}'.format(window_name)
181        msg = 'Tmux started. Please connect in another terminal with `tmux att -t {0}`'.format(window_name)
182        if d:
183            bb.event.fire(bb.event.LogExecTTY(msg, attach_cmd, 0.5, 10), d)
184        else:
185            logger.warning(msg)
186
187class Custom(Terminal):
188    command = 'false' # This is a placeholder
189    priority = 3
190
191    def __init__(self, sh_cmd, title=None, env=None, d=None):
192        self.command = d and d.getVar('OE_TERMINAL_CUSTOMCMD')
193        if self.command:
194            if not '{command}' in self.command:
195                self.command += ' {command}'
196            Terminal.__init__(self, sh_cmd, title, env, d)
197            logger.warning('Custom terminal was started.')
198        else:
199            logger.debug('No custom terminal (OE_TERMINAL_CUSTOMCMD) set')
200            raise UnsupportedTerminal('OE_TERMINAL_CUSTOMCMD not set')
201
202
203def prioritized():
204    return Registry.prioritized()
205
206def get_cmd_list():
207    terms = Registry.prioritized()
208    cmds = []
209    for term in terms:
210        if term.command:
211            cmds.append(term.command)
212    return cmds
213
214def spawn_preferred(sh_cmd, title=None, env=None, d=None):
215    """Spawn the first supported terminal, by priority"""
216    for terminal in prioritized():
217        try:
218            spawn(terminal.name, sh_cmd, title, env, d)
219            break
220        except UnsupportedTerminal:
221            pass
222        except:
223            bb.warn("Terminal %s is supported but did not start" % (terminal.name))
224    # when we've run out of options
225    else:
226        raise NoSupportedTerminals(get_cmd_list())
227
228def spawn(name, sh_cmd, title=None, env=None, d=None):
229    """Spawn the specified terminal, by name"""
230    logger.debug('Attempting to spawn terminal "%s"', name)
231    try:
232        terminal = Registry.registry[name]
233    except KeyError:
234        raise UnsupportedTerminal(name)
235
236    # We need to know when the command completes but some terminals (at least
237    # gnome and tmux) gives us no way to do this. We therefore write the pid
238    # to a file using a "phonehome" wrapper script, then monitor the pid
239    # until it exits.
240    import tempfile
241    import time
242    pidfile = tempfile.NamedTemporaryFile(delete = False).name
243    try:
244        sh_cmd = bb.utils.which(os.getenv('PATH'), "oe-gnome-terminal-phonehome") + " " + pidfile + " " + sh_cmd
245        pipe = terminal(sh_cmd, title, env, d)
246        output = pipe.communicate()[0]
247        if output:
248            output = output.decode("utf-8")
249        if pipe.returncode != 0:
250            raise ExecutionError(sh_cmd, pipe.returncode, output)
251
252        while os.stat(pidfile).st_size <= 0:
253            time.sleep(0.01)
254            continue
255        with open(pidfile, "r") as f:
256            pid = int(f.readline())
257    finally:
258        os.unlink(pidfile)
259
260    while True:
261        try:
262            os.kill(pid, 0)
263            time.sleep(0.1)
264        except OSError:
265           return
266
267def check_tmux_version(desired):
268    vernum = check_terminal_version("tmux")
269    if vernum and bb.utils.vercmp_string_op(vernum, desired, "<"):
270        return False
271    return vernum
272
273def check_tmux_pane_size(tmux):
274    import subprocess as sub
275    # On older tmux versions (<1.9), return false. The reason
276    # is that there is no easy way to get the height of the active panel
277    # on current window without nested formats (available from version 1.9)
278    if not check_tmux_version('1.9'):
279        return False
280    try:
281        p = sub.Popen('%s list-panes -F "#{?pane_active,#{pane_height},}"' % tmux,
282                shell=True,stdout=sub.PIPE,stderr=sub.PIPE)
283        out, err = p.communicate()
284        size = int(out.strip())
285    except OSError as exc:
286        import errno
287        if exc.errno == errno.ENOENT:
288            return None
289        else:
290            raise
291
292    return size/2 >= 19
293
294def check_terminal_version(terminalName):
295    import subprocess as sub
296    try:
297        cmdversion = '%s --version' % terminalName
298        if terminalName.startswith('tmux'):
299            cmdversion = '%s -V' % terminalName
300        newenv = os.environ.copy()
301        newenv["LANG"] = "C"
302        p = sub.Popen(['sh', '-c', cmdversion], stdout=sub.PIPE, stderr=sub.PIPE, env=newenv)
303        out, err = p.communicate()
304        ver_info = out.decode().rstrip().split('\n')
305    except OSError as exc:
306        import errno
307        if exc.errno == errno.ENOENT:
308            return None
309        else:
310            raise
311    vernum = None
312    for ver in ver_info:
313        if ver.startswith('Konsole'):
314            vernum = ver.split(' ')[-1]
315        if ver.startswith('GNOME Terminal'):
316            vernum = ver.split(' ')[-1]
317        if ver.startswith('MATE Terminal'):
318            vernum = ver.split(' ')[-1]
319        if ver.startswith('tmux'):
320            vernum = ver.split()[-1]
321        if ver.startswith('tmux next-'):
322            vernum = ver.split()[-1][5:]
323    return vernum
324
325def distro_name():
326    try:
327        p = Popen(['lsb_release', '-i'])
328        out, err = p.communicate()
329        distro = out.split(':')[1].strip().lower()
330    except:
331        distro = "unknown"
332    return distro
333