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