Files
MeshTerm/meshterm/core/macterminal.py
T
Jean-Pierre MartineauandClaude Opus 5 41c2cf416b The profile says a path, so three launches leave one profile
Verified on the Mac, and three of the four things it taught were the
opposite of what the code assumed.

RunCommandAsShell is false. Under *true* Terminal takes the whole command
string as the name of a program and the window reads "Command not found:
MESHTERM_REOPENED=1 / Could not create a new process and open a
pseudo-tty". The key's name says otherwise; the measurement wins.

The environment rides on /usr/bin/env rather than on a VAR=value prefix,
for the same reason. env is a real executable, so the line is valid as a
bare argv and as a shell command both, and the window cannot land on that
message however Terminal decides to read the string.

Terminal names an imported settings set after the FILE, not the name key
inside it -- and re-importing the same bytes reuses that profile, while
different bytes under the same filename add "MeshTerm 1", then
"MeshTerm 2". Opening one file four times, three identical and one a word
apart, left exactly two profiles. So the command cannot live in the
profile: it carries argv and the reader's MESHTERM_* variables, and a
profile per command line would fill their settings list with junk only
they can remove. The variable part moved behind a fixed path instead --
the profile says ~/.meshterm/launch.sh and never changes, and the script
underneath says whatever this launch needs.

Three launches with three different command lines now leave one profile
named MeshTerm, the window drawing CascadiaMonoPL-Regular at 13pt on
opaque black at 80x30, with the app running in it. The reader's default
profile is still theirs, blur and all.

shellExitAction 1 while here: tidy the window away on a clean exit, keep
it on a crash, because a crash nobody sees is a bug report nobody writes.

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

414 lines
16 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Reopening the session in a Terminal window that looks like MeshTerm.
macOS Terminal is not the classic Windows console's problem over again. It falls back
generously — ask it for a glyph its font lacks and it finds one somewhere — so nothing here
is ever a box. The trouble is *what* it finds: no Mac monospace face carries the Braille
Patterns block at all (SF Mono, Menlo, Monaco and Courier all measure zero), so the block
the charts and the map's terrain are drawn from is borrowed from a **proportional** face
and arrives at the wrong width. The screen is not empty, it is crooked, which is worse —
nothing announces itself as broken and the reader has nothing to compare it against.
The fix is a font, and a font means a Terminal profile that names it. Which raises the
question this module exists to answer carefully: **whose terminal is it?**
MeshTerm's palette is the 16-colour VT set (:data:`~meshterm.ui.theme._VT_SLOTS`) — a
deliberately crude, deliberately CGA-ish thing that the app wears on purpose. Nobody should
have to live in it to read their mail. So this module never touches the reader's default
profile and never edits their preferences: it *adds* a profile named MeshTerm, and opens
MeshTerm's own window in it. Their Terminal keeps whatever they chose, down to its
translucency; ours is opaque and crude in a window of its own, and quitting leaves nothing
behind.
That also sidesteps a trap worth recording, because it costs an evening to find. Terminal
rewrites ``~/Library/Preferences/com.apple.Terminal.plist`` **from memory when it quits**,
so anything written into that file while Terminal is running is silently discarded later —
and MeshTerm is always running inside it. Nothing here writes that file. A ``.terminal``
file is handed to Terminal and *Terminal* does the importing, which is the one path that
works from inside a live session.
The profile is generated rather than shipped, from the same theme constant the app draws
itself with, so the window can never disagree with the screen it is holding.
"""
from __future__ import annotations
import os
import plistlib
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
from .relaunch import REOPENED_ENV, own_command, wanted_size
#: The name the profile is installed under, and the name the reader sees in Terminal's
#: settings list. Stable: renaming it would strand the copy already on their machine.
PROFILE_NAME = "MeshTerm"
#: The bundled face, by its PostScript name — which is what an ``NSFont`` archive holds,
#: and not the family name Font Book shows. Installed by :func:`install_font` from the copy
#: in the package; see :mod:`meshterm.core.consolefont` for the Windows half of the same
#: idea.
FONT_FACE = "CascadiaMonoPL-Regular"
#: Points. Terminal's own default is 11; 13 is a size the braille charts read cleanly at on
#: a Retina panel without the map losing rows.
FONT_SIZE = 13.0
#: Where a user-installed font goes on macOS. No administrator rights, no installer, and
#: the reader can undo it by dragging the file out of Font Book.
USER_FONT_DIR = Path("~/Library/Fonts").expanduser()
#: Terminal's key for each palette slot, in :data:`~meshterm.ui.theme._VT_SLOTS` order —
#: the standard ANSI 16, dim bank then bright bank.
_ANSI_KEYS: tuple[str, ...] = (
"ANSIBlackColor",
"ANSIRedColor",
"ANSIGreenColor",
"ANSIYellowColor",
"ANSIBlueColor",
"ANSIMagentaColor",
"ANSICyanColor",
"ANSIWhiteColor",
"ANSIBrightBlackColor",
"ANSIBrightRedColor",
"ANSIBrightGreenColor",
"ANSIBrightYellowColor",
"ANSIBrightBlueColor",
"ANSIBrightMagentaColor",
"ANSIBrightCyanColor",
"ANSIBrightWhiteColor",
)
def _archive(objects: list[object]) -> bytes:
"""One ``NSKeyedArchiver`` document, as Terminal stores every colour and font.
Terminal keeps these as nested binary plists inside the profile — an archived
``NSColor`` or ``NSFont`` rather than a number and a string, because the profile is
written by AppKit rather than for us. The shape is small and fixed, so it is built
here rather than reached for through a dependency.
Args:
objects: The ``$objects`` array, with ``$null`` already at index 0.
Returns:
The encoded archive. Binary, because :class:`plistlib.UID` has no XML spelling.
"""
return plistlib.dumps(
{
"$archiver": "NSKeyedArchiver",
"$objects": objects,
"$top": {"root": plistlib.UID(1)},
"$version": 100000,
},
fmt=plistlib.FMT_BINARY,
)
def ns_color(rgb: str) -> bytes:
"""An archived ``NSColor`` for a ``#rrggbb`` string.
Args:
rgb: The colour, as the theme spells it.
Returns:
The archive Terminal expects in a colour key.
"""
channels = (int(rgb[index : index + 2], 16) / 255 for index in (1, 3, 5))
# Calibrated RGB, space-separated to six places and NUL-terminated: AppKit's own
# spelling, matched exactly because Terminal parses it rather than tolerating it.
packed = " ".join(f"{value:.6f}" for value in channels).encode("ascii") + b"\x00"
return _archive(
[
"$null",
{"$class": plistlib.UID(2), "NSColorSpace": 1, "NSRGB": packed},
{"$classes": ["NSColor", "NSObject"], "$classname": "NSColor"},
]
)
def ns_font(face: str = FONT_FACE, size: float = FONT_SIZE) -> bytes:
"""An archived ``NSFont``.
Args:
face: The PostScript name of the face.
size: The size in points.
Returns:
The archive Terminal expects in the ``Font`` key.
"""
return _archive(
[
"$null",
{
"$class": plistlib.UID(3),
"NSName": plistlib.UID(2),
"NSSize": float(size),
"NSfFlags": 16,
},
face,
{"$classes": ["NSFont", "NSObject"], "$classname": "NSFont"},
]
)
def launch_command() -> str:
"""The shell line that starts this session again, for the profile to run.
Terminal starts the command from a **fresh login shell**, which is why this is a
string rather than an argv and why it carries what it needs explicitly. Nothing of the
current process's environment survives the hop — good news for the ``_PYI*`` variables
a frozen build must not pass on (see
:func:`~meshterm.core.relaunch.child_environment`, which exists for the Windows side
where they *do* survive), and bad news for a ``MESHTERM_HOME`` the reader set for this
invocation alone, which the README itself suggests doing. So every ``MESHTERM_*``
variable currently set is carried across, and the reopened-session marker with them.
The variables ride on ``/usr/bin/env`` rather than on a ``VAR=value`` prefix, and that
is not a stylistic choice. Terminal's own handling of the string is not something to
lean on: measured on macOS 26, the same command line runs under ``RunCommandAsShell``
*false* and fails under *true*, where Terminal takes the whole thing as the name of a
program and puts this on the screen::
Command not found: MESHTERM_REOPENED=1
Could not create a new process and open a pseudo-tty
``env`` is a real executable, so it is a valid argv *and* a valid shell line, and the
window cannot land on that message however Terminal decides to read it. ``env`` execs
the program in its own place, so nothing lingers behind it either — the window holds
MeshTerm and nothing else, and closing MeshTerm does not drop to a prompt.
Returns:
A command line Terminal can run with or without a shell.
"""
carried = {name: value for name, value in os.environ.items() if name.startswith("MESHTERM_")}
carried[REOPENED_ENV] = "1"
assignments = " ".join(
f"{name}={shlex.quote(value)}" for name, value in sorted(carried.items())
)
program = " ".join(shlex.quote(part) for part in own_command())
return f"/usr/bin/env {assignments} {program}"
def build_profile(command: str | None = None) -> dict[str, object]:
"""The MeshTerm profile, as Terminal's preferences hold it.
Every colour comes from :data:`~meshterm.ui.theme._VT_SLOTS`, so the window and the
app it is holding cannot disagree — change the theme and this follows on the next
write. The window size is
:func:`~meshterm.core.relaunch.wanted_size`'s, the same one Windows Terminal is asked
for.
Args:
command: A shell line for the window to run on open, or ``None`` for a profile
that just sits in the list waiting to be chosen.
Returns:
The profile dictionary, ready to write as a ``.terminal`` file.
"""
from ..ui.theme import _VT_SLOTS
cols, rows = wanted_size()
profile: dict[str, object] = {
"name": PROFILE_NAME,
"type": "Window Settings",
"ProfileCurrentVersion": 2.07,
"Font": ns_font(),
"FontAntialias": True,
"FontWidthSpacing": 1.0,
# Opaque on purpose. A braille raster over a translucent ground is unreadable, and
# a translucent profile is a perfectly reasonable thing for the reader to have --
# which is the whole argument for MeshTerm having a window of its own.
"BackgroundColor": ns_color("#000000"),
"TextColor": ns_color("#aaaaaa"),
"TextBoldColor": ns_color("#ffffff"),
"SelectionColor": ns_color("#555555"),
"CursorColor": ns_color("#55ffff"),
"CursorType": 0,
"BlinkText": False,
# Bold is bold, not a jump to the bright bank. The theme states brightness as a
# colour (see the wordmark's own note); letting Terminal promote it as well would
# land a bank too high.
"UseBrightBold": False,
"columnCount": cols,
"rowCount": rows,
"ScrollbackLines": 10000,
"ShouldLimitScrollback": 0,
"ShowActiveProcessInTitle": True,
"ShowDimensionsInTitle": False,
"ShowWindowSettingsNameInTitle": False,
# Close the window when MeshTerm exits cleanly, and keep it when it does not --
# a crash the reader never sees is a bug report nobody can write.
"shellExitAction": 1,
}
for key, (_slot, _label, rgb) in zip(_ANSI_KEYS, _VT_SLOTS, strict=True):
profile[key] = ns_color(rgb)
if command is not None:
profile["CommandString"] = command
# False, measured. Under *true* Terminal takes the whole string as a program name
# and the window says "Command not found: ..."; under false it runs. The opposite
# of what the key's name suggests, which is why it is written down.
profile["RunCommandAsShell"] = False
return profile
def launcher_path() -> Path:
"""The little script the profile runs, beside the profile itself."""
from .config import default_config_dir
return default_config_dir() / "launch.sh"
def write_launcher() -> Path:
"""Write the launcher, and return where it landed.
This exists for one reason, and it is the finding that shaped this module. Terminal
names an imported settings set **after the file** — ``MeshTerm.terminal`` becomes the
profile "MeshTerm" — and re-importing the *same bytes* reuses that profile, while
importing **different** bytes under the same filename creates "MeshTerm 1", then
"MeshTerm 2". Measured, by opening one file four times: three identical imports left
one profile, and the fourth, one word different, left two.
So the command cannot live in the profile. It carries ``sys.argv`` and the reader's
``MESHTERM_*`` variables, which differ between ``meshterm`` and ``meshterm --mock``,
and a profile per command line would fill the reader's settings list with junk that
only they can remove.
Moving the variable part behind a fixed path fixes it exactly: the profile says
``~/.meshterm/launch.sh`` and never changes, and this file underneath it says whatever
this launch happens to need.
Returns:
The path written, executable.
"""
path = launcher_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"#!/bin/sh\n"
"# Written by MeshTerm each time it reopens itself. Safe to delete.\n"
f"exec {launch_command()}\n",
encoding="utf-8",
)
path.chmod(0o755)
return path
def profile_path() -> Path:
"""Where the generated ``.terminal`` file is kept.
In MeshTerm's own directory rather than the Desktop: it is ours, it is the one place
that already survives an upgrade, and it moves with ``MESHTERM_HOME``.
"""
from .config import default_config_dir
return default_config_dir() / f"{PROFILE_NAME}.terminal"
def write_profile(*, command: str | None = None) -> Path:
"""Write the profile out, and return where it landed.
Args:
command: A shell line for the window to run, or ``None`` for a bare profile.
Returns:
The path written.
"""
path = profile_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(plistlib.dumps(build_profile(command), fmt=plistlib.FMT_XML))
return path
def font_installed() -> bool:
"""Whether the bundled face is already in the reader's font directory."""
from .consolefont import BUNDLED_FONT
return (USER_FONT_DIR / BUNDLED_FONT.name).is_file()
def install_font() -> bool:
"""Copy the bundled face into the reader's own font directory.
The one thing here that writes outside MeshTerm's own folder, which is why it is the
one thing worth asking about: a single file, in the reader's own Library, no
administrator rights, no installer, removable by dragging it out of Font Book. It
changes nothing about how their Terminal looks — it only makes the face available for
a profile to name.
Safe to repeat: an existing copy is left alone.
Returns:
Whether the font is installed and usable, including when it already was.
"""
from .consolefont import BUNDLED_FONT
if sys.platform != "darwin" or not BUNDLED_FONT.is_file():
return False
target = USER_FONT_DIR / BUNDLED_FONT.name
if target.is_file():
return True
try:
USER_FONT_DIR.mkdir(parents=True, exist_ok=True)
shutil.copy2(BUNDLED_FONT, target)
except OSError:
return False
return True
def in_apple_terminal() -> bool:
"""Whether this session is drawing into macOS Terminal.
iTerm2, Ghostty, kitty, WezTerm and VS Code all set ``TERM_PROGRAM`` to their own
name, and several of them have both truecolor and a font the reader chose deliberately
— none of them wants anything this module offers.
"""
return sys.platform == "darwin" and os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
def available() -> bool:
"""Whether reopening in MeshTerm's own window is a thing worth doing here.
``False`` in a session that was itself reopened, which is what keeps a relaunch from
chaining into another one: the profile's own command carries the marker, because
Terminal starts it from a fresh shell that inherits nothing from us.
"""
return in_apple_terminal() and not os.environ.get(REOPENED_ENV)
def reopen() -> bool:
"""Open MeshTerm in a window using its own profile, and report whether it started.
``open`` hands the file to Terminal, and **Terminal** imports the profile and opens the
window — the only route that works from inside a live session, since Terminal discards
external edits to its preferences when it quits.
The new window is not a child in any meaningful sense: the caller exits immediately
afterwards and the two never talk.
Two files, not one: the launcher carries what varies and the profile points at it, so
the profile's bytes are the same on every launch and Terminal reuses the one settings
set instead of adding a numbered copy. :func:`write_launcher` has the measurement.
Returns:
Whether ``open`` was launched. ``False`` leaves the caller exactly where it was,
so a failure here is a detour and never a dead end.
"""
if not available():
return False
try:
launcher = write_launcher()
path = write_profile(command=str(launcher))
except OSError:
return False
try:
subprocess.Popen( # noqa: S603 - the argv is ours, not the reader's
["/usr/bin/open", str(path)],
close_fds=True,
)
except OSError:
return False
return True