Files
MeshTerm/meshterm/core/hostinfo.py
T
Jean-Pierre MartineauandClaude Opus 5 6046b4ff0e A bug report should be one page, and a page nobody has to read first
A report that opens with "it crashed" costs three round trips before
anyone knows which MeshTerm, on which OS, in which terminal, talking to
which radio. The person who hit the bug is the one least able to answer:
half of it is resolved at boot and shown on no screen -- whether icons
draw, whether powerline separators do, which platform flavour resolved and
why. Diagnostics states all of it at once, as a page under This app and as
meshterm diagnostics.

The page is a bare frame, and that is mechanical rather than aesthetic.
The one thing it is for is getting its text out of the terminal and into
an issue, and a terminal is selected by dragging: a border is characters
on the same lines as the content, so every line of a line-wise selection
arrives with a box-drawing glyph welded to each end. A header and a footer
hint arrive as text that looks like part of the report to whoever reads
the paste. So the page draws its rows and nothing besides, the frame the
share screen already uses for the same kind of reason.

Nothing in the block is private, and that has to be a property rather than
a habit, because the whole premise is that it gets pasted in public by
somebody who did not read it. No pairing PIN, no admin password, no
channel secret, no private key, no position, no contact's name or key. The
mesh arrives as aggregate -- a row count per table and the span the
observations cover -- which is the half that explains a bug without naming
anyone the reporter talks to. Two tests hold the line from opposite sides:
one plants real secrets in the stores that hold them and asserts none came
out, and one refuses the key *names* such a field would take, so a value
the suite cannot plant is still caught by what it would arrive as.

The radio is asked and never required. A report about a companion that
will not connect is exactly the report most worth filing, so the failure
becomes connected=no with the radio's own words in error, and every other
fact still arrives.

Table counts come off sqlite_master rather than a list written down here,
so a table added later starts being reported the day it lands; the
interesting count is always the table nobody expected to be full. Only
overridden preferences are listed -- the defaults are in the source and
identical for everyone.

Both faces share one projection of a facts block, so the page cannot drift
from what the command prints: a field added to the report reaches both
without anyone remembering to. Only the layout differs, in one respect --
the scripted face crops a value too wide for its lane, which is right for
a record somebody will split on whitespace and wrong for a page whose
whole job is to be complete, so a long value hangs under its own block
instead. A path also gains the lane constructor it never had; the machine
face had always spelled one correctly and the plain face could not render
one at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 15:58:12 -04:00

239 lines
9.2 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""What MeshTerm is running on, read once so a bug report does not have to be interviewed.
Every value here answers a question that otherwise costs a round trip with whoever
reported the problem: which build of MeshTerm, on which OS, in which terminal, how wide,
with which colour depth. None of it is about the mesh and none of it is private — this
module deliberately knows nothing about contacts, keys, positions or messages, so the
block it feeds can be pasted in public without anyone having to read it first.
Two probes are **ladders over environment variables** rather than protocol queries, which
is a concession worth naming: a terminal has no portable way to be asked who it is. The
one sequence that comes close (``CSI > q``) needs a live tty, a reply that may never
arrive, and a timeout budget on a path that must never hang. So each terminal is
identified by the variable it sets about *itself* — which is the same ladder
:func:`~meshterm.ui.termfont.detect_terminal_font` already climbs for the font — and
anything unrecognised falls back to ``TERM`` rather than to a guess.
"""
from __future__ import annotations
import os
import platform
import shutil
import sys
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
#: The first Windows 11 build. Windows 11 still reports its release as ``"10"`` through
#: every documented API, so the build number is the only thing that separates the two.
_WINDOWS_11_BUILD = 22000
#: Terminals that announce themselves, by the variable each one sets. Ordered most to
#: least specific: an emulator hosting another (VS Code's terminal, a multiplexer) sets
#: its own marker *and* inherits the outer one, so the inner identity has to win.
#:
#: ``TERM_PROGRAM`` is checked separately, between these and the ``TERM`` fallback,
#: because its *value* is the name — it identifies terminals this table never has to list.
_TERMINAL_VARS: tuple[tuple[str, str], ...] = (
("WT_SESSION", "Windows Terminal"),
("ConEmuPID", "ConEmu"),
("TERMINAL_EMULATOR", ""), # JetBrains IDEs put the name in the value
("KITTY_WINDOW_ID", "kitty"),
("ALACRITTY_WINDOW_ID", "Alacritty"),
("KONSOLE_VERSION", "Konsole"),
("GNOME_TERMINAL_SCREEN", "GNOME Terminal"),
("VTE_VERSION", "VTE-based"),
)
#: What ``TERM_PROGRAM`` calls terminals whose own name is not what a person calls them.
#: Only the ones that genuinely differ; every other value passes through as it is, which
#: is what keeps this from growing into a catalogue of every terminal there is.
_TERM_PROGRAM_NAMES: dict[str, str] = {
"Apple_Terminal": "Terminal.app",
"vscode": "VS Code",
}
@dataclass(frozen=True, slots=True)
class Host:
"""The machine and the Python running MeshTerm.
Attributes:
install: How this copy was installed — ``"frozen"`` (a one-file build, which has
its own bootloader, its own relaunch rules and no source tree to read),
``"source"`` (a checkout, so a commit can be asked for), or ``"package"``
(a wheel from an index). The three fail in different ways and the reporter
usually does not know which one they have.
os: The flavour and its version as a person writes it — ``Windows 11``,
``macOS 15.6``, ``Debian GNU/Linux 12 (bookworm)``.
os_build: The exact build under that name: a Windows build number, or the kernel
release everywhere else. What separates two hosts that call themselves the
same thing.
arch: The processor architecture, which is what tells an arm64 build from an
x86_64 one when a binary misbehaves.
python: The interpreter version, three parts.
"""
install: str
os: str
os_build: str
arch: str
python: str
@dataclass(frozen=True, slots=True)
class Terminal:
"""The terminal MeshTerm is drawing into, and what it can draw.
Attributes:
program: The emulator's name, or ``TERM``'s value where nothing identified
itself, or ``None`` when even that is unset.
term: The ``TERM`` value, verbatim. Kept beside ``program`` rather than folded
into it: a terminal lying about ``TERM`` is itself a common cause of a
rendering report.
colorterm: The ``COLORTERM`` value, which is how a terminal claims truecolor and
the first thing to check when a screen arrives in the wrong colours.
cols: The terminal's width in cells.
rows: The terminal's height in cells.
over_ssh: Whether this is an ssh session. It changes what is knowable — the font
lives on the far client — and it is why several other verdicts here read
``unknown`` rather than wrong.
"""
program: str | None
term: str | None
colorterm: str | None
cols: int
rows: int
over_ssh: bool
def install_kind() -> str:
"""How this copy of MeshTerm got here: ``frozen``, ``source`` or ``package``.
A frozen build is unambiguous — the bootloader sets the flag. Past that, the question
is whether there is a repository above the package: a checkout can be asked for its
commit and can carry uncommitted work, and a wheel can do neither.
Returns:
One of ``"frozen"``, ``"source"`` or ``"package"``.
"""
if getattr(sys, "frozen", False):
return "frozen"
# meshterm/core/hostinfo.py -> meshterm/core -> meshterm -> the tree that holds it
if (Path(__file__).resolve().parents[2] / ".git").exists():
return "source"
return "package"
def host() -> Host:
"""Read the machine's own facts.
Returns:
The :class:`Host` block.
"""
name, build = _os_name_and_build()
return Host(
install=install_kind(),
os=name,
os_build=build,
arch=platform.machine() or "unknown",
python=platform.python_version(),
)
def terminal(environ: Mapping[str, str] | None = None) -> Terminal:
"""Read the terminal's own facts.
Args:
environ: The environment to inspect (defaults to ``os.environ``), injectable so
the ladder can be tested without a terminal of the right kind to hand.
Returns:
The :class:`Terminal` block.
"""
env = os.environ if environ is None else environ
size = shutil.get_terminal_size()
return Terminal(
program=terminal_program(env),
term=env.get("TERM") or None,
colorterm=env.get("COLORTERM") or None,
cols=size.columns,
rows=size.lines,
over_ssh=bool(env.get("SSH_TTY") or env.get("SSH_CONNECTION")),
)
def terminal_program(environ: Mapping[str, str] | None = None) -> str | None:
"""Name the terminal emulator from what it says about itself.
The ladder: the terminals in :data:`_TERMINAL_VARS` that set a marker variable, then
``TERM_PROGRAM`` (whose *value* is the name, so it covers terminals this module has
never heard of), then ``TERM`` as the last honest thing there is to say.
Args:
environ: The environment to inspect (defaults to ``os.environ``).
Returns:
The terminal's name, or ``None`` when nothing in the environment names one.
"""
env = os.environ if environ is None else environ
for var, name in _TERMINAL_VARS:
value = env.get(var)
if value:
return name or value
program = env.get("TERM_PROGRAM")
if program:
return _TERM_PROGRAM_NAMES.get(program, program)
return env.get("TERM") or None
def _os_name_and_build() -> tuple[str, str]:
"""The OS as a person writes it, and the exact build under that name."""
system = platform.system()
if system == "Windows":
return _windows()
if system == "Darwin":
version = platform.mac_ver()[0]
return (f"macOS {version}" if version else "macOS"), platform.release()
if system == "Linux":
return (_pretty_name() or "Linux"), platform.release()
return (system or "unknown"), platform.release()
def _windows() -> tuple[str, str]:
"""Windows by the name it is sold under, and its build number.
``platform.win32_ver`` reports release ``"10"`` on Windows 11 as well — the rename
never reached the version APIs — so the build number is what decides which one this
is. The build is also the useful half of the pair: it is what a fix is shipped
against.
"""
release, version, _service_pack, _kind = platform.win32_ver()
try:
build = int(version.rsplit(".", 1)[-1])
except ValueError:
build = 0
name = "Windows 11" if build >= _WINDOWS_11_BUILD else f"Windows {release or '?'}"
return name, version or platform.version()
def _pretty_name() -> str | None:
"""The distribution's own name for itself, from ``/etc/os-release``.
The one place a Linux flavour is stated by the system rather than inferred, and the
reason a report can say *Calculinux* or *Raspberry Pi OS* instead of ``Linux``.
A host without the file (or without permission to read it) simply has no answer.
"""
try:
text = Path("/etc/os-release").read_text(encoding="utf-8")
except OSError:
return None
for line in text.splitlines():
key, _, value = line.partition("=")
if key == "PRETTY_NAME":
return value.strip().strip('"') or None
return None