File size: 5,242 Bytes
2aa8b3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
"""What happens when somebody double-clicks the executable.

**One decision, stated plainly: no arguments means the screen, not the usage
text.** A command-line program with no arguments conventionally prints how to
use it and exits, and that convention is right for a program invoked from a
shell by somebody who mistyped. It is wrong for this one, because the most
common way this is started is a double-click by a person who has never seen a
terminal, and answering them with an argument grammar is answering a question
they did not ask.

So: no arguments at all, and a console that can be read, opens the setup and
then the screen. Any argument at all, or a console that cannot be read, behaves
exactly as before. Nobody's script changes, and `--help` still prints help.

**The distinction that makes this safe** is that this module produces a command
line and hands it to `cli.main`. It does not have its own way to start a worker.
Everything `main` refuses to do when typed at, it refuses to do here: a remote
server without permission, a plaintext one without a second permission, a
missing pairing code. This file asks questions and formats an answer; it decides
nothing about what a worker may do.
"""

from __future__ import annotations

import os
import sys
from typing import Any, Callable, Optional, Sequence

#: Set by a launcher that has already decided. Mostly for tests, which must be
#: able to exercise both paths without depending on whether pytest happens to
#: be attached to a terminal.
FORCE_ENV_VAR = "DISTINCT_DESKTOP"


def wants_desktop(
    argv: Optional[Sequence[str]] = None,
    *,
    environ: Optional[dict] = None,
    isatty: Optional[Callable[[], bool]] = None,
) -> bool:
    """Whether this invocation should open the screen rather than parse flags.

    Three things have to be true, and the third is the one that matters. There
    must be no arguments, because an argument is somebody being specific and
    being overridden is infuriating. The console must be readable, because the
    setup screen asks questions and a screen that asks a question nobody can
    answer is a hang. And the environment override must not say otherwise,
    which is how a test drives either path deliberately.
    """

    environ = os.environ if environ is None else environ
    forced = environ.get(FORCE_ENV_VAR)
    if forced == "0":
        return False
    if forced == "1":
        return True

    arguments = list(sys.argv[1:] if argv is None else argv)
    if arguments:
        return False
    if isatty is None:
        def isatty() -> bool:
            try:
                return bool(sys.stdin.isatty() and sys.stdout.isatty())
            except (AttributeError, ValueError):
                return False
    return bool(isatty())


def run(*, main: Optional[Callable[..., int]] = None, settings_io: Any = None) -> int:
    """Ask, remember, and start. Returns the exit code the worker returns.

    ``settings_io`` is the `firstrun` module by default and is injectable so a
    test can drive the whole flow against a temporary file rather than against
    whatever is in the person's real configuration directory.
    """

    from . import firstrun
    from .dashboard import ActivityLog
    from .tui import SetupApp

    settings_io = settings_io or firstrun
    if main is None:
        from .cli import main as main

    settings = settings_io.load()
    answered = SetupApp(settings, first_run=not settings.is_complete()).run()
    if answered is None:
        print("Nothing was started.", file=sys.stderr)
        return 0
    settings, code = answered
    if not code:
        print("No pairing code, so nothing was started.", file=sys.stderr)
        return 0

    where = settings_io.save(settings)
    if where is None:
        print(
            "These answers could not be saved, so they will be asked again next "
            "time. The worker still starts.",
            file=sys.stderr,
        )

    argv = settings_io.to_argv(settings, code) + ["--dashboard"]

    # Everything the worker prints from here is bound for the screen, and the
    # screen does not exist yet: pairing, the catalogue, the approval question
    # and possibly a long download all happen first. So the log mirrors to the
    # real stderr until the dashboard takes the terminal over, and somebody
    # watching a slow start sees it happening rather than a blank window.
    log = ActivityLog(mirror=sys.stderr)
    log._distinct_activity_log = log  # noqa: SLF001 - see the note below
    previous = sys.stderr
    sys.stderr = log
    try:
        return int(main(argv))
    finally:
        sys.stderr = previous


# `cli._run_with_dashboard` finds the log by asking `sys.stderr` for
# `_distinct_activity_log`, rather than by taking a parameter. That is the one
# piece of indirection here worth defending: `main` has a long argument list
# already, the log is a property of how this process was launched rather than
# of what the worker was asked to do, and threading it through would put a
# display concern into every signature between here and there. The attribute is
# set on the object itself, so a `sys.stderr` that is not one of these simply
# answers None and the dashboard makes its own.