Compare commits
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# Kept in step with meshterm/assets/pages/support.md deliberately. If the app offers a
|
||||
# way to chip in and this file doesn't, the two have drifted and one of them is lying.
|
||||
buy_me_a_coffee: johnputer
|
||||
custom:
|
||||
- "https://www.patreon.com/c/MeshTerm"
|
||||
- "https://tinyurl.com/SupportMeshTerm"
|
||||
@@ -0,0 +1,183 @@
|
||||
name: Bug report
|
||||
description: Report a problem with MeshTerm
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Hopefully you never need this form.
|
||||
|
||||
If you do: how much of this you fill in really does decide what happens next. A
|
||||
report I can act on gets worked on. A vague one usually doesn't, because I can't
|
||||
do anything with it. If it isn't worth describing properly, it's probably not
|
||||
worth fixing.
|
||||
|
||||
Most of these take ten seconds each.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What's the problem
|
||||
description: What happens, and what you expected to happen instead.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: how-often
|
||||
attributes:
|
||||
label: How often does it happen
|
||||
options:
|
||||
- Every single time
|
||||
- Often, but not always
|
||||
- Now and then, no pattern I can see
|
||||
- Once, and I haven't seen it since
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: How do I make it happen
|
||||
description: >
|
||||
The steps, in order, starting from launching MeshTerm. Screens and keys beat
|
||||
descriptions — "Contacts, Enter on a node, Routes tab, Enter on the second route"
|
||||
is worth more than "open a trace".
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: workaround
|
||||
attributes:
|
||||
label: Is there a way around it
|
||||
description: Anything that avoids it, or gets you unstuck. Say so even if it's ugly.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: MeshTerm version
|
||||
description: It's on the splash screen when you start, and on the About page.
|
||||
placeholder: "0.2.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: hardware
|
||||
attributes:
|
||||
label: What are you running MeshTerm on
|
||||
description: >
|
||||
Desktop, laptop, Mac, or a handheld — and if it's a handheld, which one.
|
||||
placeholder: "ClockworkPi uConsole (CM4), or: desktop PC"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Which OS, exact version
|
||||
placeholder: "Windows 11 24H2 / macOS 15.3 / Debian 12"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: terminal
|
||||
attributes:
|
||||
label: Which terminal, and how big
|
||||
description: >
|
||||
PowerShell, Windows Terminal, iTerm2, the PicoCalc console, ssh from something.
|
||||
Size in columns x rows if you know it — a lot of layout bugs are really terminal
|
||||
bugs.
|
||||
placeholder: "Windows Terminal, 120x40"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: companion
|
||||
attributes:
|
||||
label: Which companion device
|
||||
description: >
|
||||
Be precise. There are a lot of these and they don't behave the same. "A Xiao"
|
||||
doesn't narrow it down — "Seeed XIAO nRF52840 + Wio-SX1262" does. Leave blank if
|
||||
no radio was involved.
|
||||
placeholder: "Heltec V3 / RAK4631 / XIAO nRF52840 + Wio-SX1262"
|
||||
|
||||
- type: input
|
||||
id: firmware
|
||||
attributes:
|
||||
label: Which firmware version is on it
|
||||
placeholder: "MeshCore 1.15.0"
|
||||
|
||||
- type: dropdown
|
||||
id: transport
|
||||
attributes:
|
||||
label: How is it connected
|
||||
options:
|
||||
- USB / serial
|
||||
- Bluetooth
|
||||
- TCP
|
||||
- SPI-to-TCP bridge
|
||||
- Something else (say so below)
|
||||
- No radio — simulator / --mock
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: flavour
|
||||
attributes:
|
||||
label: Regular or PicoCalc
|
||||
description: >
|
||||
MeshTerm draws itself differently on a PicoCalc — narrower, fewer colours, a
|
||||
different font. A bug in one often doesn't exist in the other, so this saves a
|
||||
round of guessing.
|
||||
options:
|
||||
- Regular (desktop, laptop, ssh)
|
||||
- PicoCalc handheld
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: mock
|
||||
attributes:
|
||||
label: Does it still happen with `meshterm --mock`
|
||||
description: >
|
||||
`--mock` runs the whole app against a fake radio. If the bug survives that, it's
|
||||
in MeshTerm and I can chase it without hardware. If it doesn't, it's in the link
|
||||
or the radio, which is a different hunt. Skip it if the bug is about connecting
|
||||
in the first place.
|
||||
options:
|
||||
- "Yes, still happens with --mock"
|
||||
- "No, --mock is fine"
|
||||
- "Haven't tried"
|
||||
- "Doesn't apply"
|
||||
|
||||
- type: textarea
|
||||
id: error
|
||||
attributes:
|
||||
label: The exact error, if there was one
|
||||
description: >
|
||||
Copy it as text rather than photographing the screen — text can be searched, and
|
||||
a photo of a terminal is hard to read. Paste the whole traceback if it crashed.
|
||||
render: text
|
||||
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
label: The log file
|
||||
description: >
|
||||
MeshTerm keeps one at `~/.meshterm/meshterm.log` (or wherever `MESHTERM_HOME`
|
||||
points). Attach it, or paste the last twenty lines. If the problem is hard to
|
||||
catch, set **Log detail** to `DEBUG` on the Preferences page, do it once more,
|
||||
and send that instead — it records a great deal more.
|
||||
render: text
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: >
|
||||
Did it used to work? Did it start after an update? Screenshots of a wrong-looking
|
||||
screen are genuinely useful here, even though text is better for errors.
|
||||
@@ -0,0 +1,13 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Not sure it's a bug? Ask on Discord
|
||||
url: https://discord.gg/AZwe5Uvb3S
|
||||
about: >
|
||||
Setup trouble, "is it supposed to do this", or anything you want a second opinion
|
||||
on. The support forum is the place. Someone running your radio has probably hit it.
|
||||
|
||||
- name: No GitHub account? Report it on Discord instead
|
||||
url: https://discord.gg/AZwe5Uvb3S
|
||||
about: >
|
||||
Drop reproducible bugs in #bugs and they'll get moved here. Being told beats
|
||||
filing it tidily.
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Feature request
|
||||
description: Suggest a feature or an improvement
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
MeshTerm is one person's spare time, so the honest answer to a good idea is often
|
||||
"yes, eventually". Saying what you are actually trying to do — rather than the
|
||||
feature you have in mind — often finds something that already works.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What are you trying to do
|
||||
description: The situation you are in, not the feature. What are you trying to find out about your mesh?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: What you had in mind
|
||||
description: If you have a shape in mind — a screen, a key, a subcommand — describe it.
|
||||
|
||||
- type: textarea
|
||||
id: today
|
||||
attributes:
|
||||
label: What you do today instead
|
||||
description: The workaround, if there is one. This says more about how much the feature is worth than anything else.
|
||||
@@ -0,0 +1,16 @@
|
||||
## What this changes, and why
|
||||
|
||||
<!-- The why matters more than the what — the diff already says the what. -->
|
||||
|
||||
## Related issue
|
||||
|
||||
<!-- CONTRIBUTING asks for an issue first on anything non-trivial, so a large PR with no
|
||||
issue behind it will get a conversation before it gets a review. Link it here. -->
|
||||
|
||||
## Before you mark it ready
|
||||
|
||||
- [ ] `python -m pytest -q` passes, including the dual-platform gallery gate
|
||||
- [ ] `ruff check .` and `ruff format --check .` are clean
|
||||
- [ ] New screens, rows and hints go through the helpers in `ui/menus.py`, and follow the
|
||||
lexicon and UX standards in `CLAUDE.md`
|
||||
- [ ] Anything user-visible has a line in `CHANGELOG.md` under `[Unreleased]`
|
||||
@@ -0,0 +1,26 @@
|
||||
# Weekly, and small on purpose. A solo maintainer reviewing five open dependency PRs
|
||||
# every Monday stops reviewing them at all, so the caps are low enough to stay readable.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: pip
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
# One PR for the routine noise; anything major still arrives on its own so it
|
||||
# gets read on its own.
|
||||
minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
commit-message:
|
||||
prefix: deps
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: ci
|
||||
@@ -0,0 +1,119 @@
|
||||
# The three commands CONTRIBUTING.md asks a contributor to run, run for them.
|
||||
#
|
||||
# Split in two on purpose. Style is a property of the source, not of the machine, so
|
||||
# `lint` runs once and its failure is unambiguous. Behaviour is a property of the
|
||||
# machine, so `test` runs everywhere we claim to work — the description promises
|
||||
# Windows, macOS and Linux, and until this file existed that promise had only ever been
|
||||
# checked on one of them.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# Nothing here writes to the repository; say so rather than inherit the default.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A second push while the first is still running makes the first answer obsolete.
|
||||
# Cancel it — on a private repo those are metered minutes spent on a stale commit.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
|
||||
- run: pip install -e ".[dev]"
|
||||
|
||||
# Both gates, and the second runs even when the first fails: they report
|
||||
# different things and seeing only the earlier one wastes a round trip.
|
||||
- run: ruff check .
|
||||
- if: ${{ !cancelled() }}
|
||||
run: ruff format --check .
|
||||
|
||||
test:
|
||||
name: test ${{ matrix.os }} py${{ matrix.python }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
# Let every leg finish. The point of a run is the whole picture of what broke,
|
||||
# and one red leg cancelling its siblings hides the rest until the next push.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Shaped for a *private* repo, where minutes are metered and billed by a
|
||||
# multiplier: Linux 1x, Windows 2x, macOS 10x. So Linux carries the full
|
||||
# Python range and the costly runners cover the ends of it, which is where a
|
||||
# version-dependent break actually shows up.
|
||||
#
|
||||
# Once the repo is public, standard runners are unmetered: widen this to every
|
||||
# OS times every version and stop thinking about it.
|
||||
include:
|
||||
- { os: ubuntu-latest, python: "3.10" }
|
||||
- { os: ubuntu-latest, python: "3.11" }
|
||||
- { os: ubuntu-latest, python: "3.12" }
|
||||
- { os: ubuntu-latest, python: "3.13" }
|
||||
- { os: windows-latest, python: "3.10" }
|
||||
- { os: windows-latest, python: "3.13" }
|
||||
- { os: macos-latest, python: "3.13" }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
|
||||
- run: pip install -e ".[dev]"
|
||||
|
||||
# Includes tests/test_gallery.py, the dual-platform readability gate: every
|
||||
# screen has to stay readable at 72 columns regular and 53 on the PicoCalc, and
|
||||
# every PicoCalc case is a hard failure rather than a warning.
|
||||
- run: python -m pytest -q
|
||||
|
||||
# Builds the distributions and checks them the way PyPI would, publishing nothing.
|
||||
# Worth its minute: packaging breaks silently and is otherwise only discovered on
|
||||
# the one day you least want to discover it. The wheel is installed into a clean
|
||||
# environment and the command run from outside the source tree, because "it built"
|
||||
# and "it works when installed" are different claims.
|
||||
build:
|
||||
name: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- run: pip install build twine
|
||||
|
||||
- run: python -m build
|
||||
|
||||
- run: twine check dist/*
|
||||
|
||||
- name: Install the wheel clean and run the command
|
||||
run: |
|
||||
python -m venv /tmp/fresh
|
||||
/tmp/fresh/bin/pip install --quiet dist/*.whl
|
||||
cd /tmp
|
||||
/tmp/fresh/bin/meshterm --help > /dev/null
|
||||
# A tool-provided subcommand, not just --help: the tools are discovered at
|
||||
# runtime, so a broken package still answers --help perfectly well.
|
||||
/tmp/fresh/bin/meshterm contacts --help > /dev/null
|
||||
echo "the wheel installs, the console script runs, and its tools registered"
|
||||
@@ -0,0 +1,103 @@
|
||||
# Puts the downloadable builds on the repository's Releases page.
|
||||
#
|
||||
# Nothing here leaves GitHub. While the repository is private the release is private too,
|
||||
# visible and downloadable only by people who can see the repo. This is *not* the PyPI
|
||||
# publish — that is `release.yml`, it is a separate act, and it does not run from here.
|
||||
#
|
||||
# Triggered by pushing a version tag, which is the moment a release exists in git anyway:
|
||||
#
|
||||
# git tag -a v0.3.0 -m "MeshTerm 0.3.0"
|
||||
# git push origin v0.3.0
|
||||
#
|
||||
# The binaries are built by `installers.yml`, called rather than copied, so the files
|
||||
# people download are made exactly the way the ones CI checks on every push are.
|
||||
name: GitHub release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing tag to build a release from (e.g. v0.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Mark as a pre-release (keeps it out of 'Latest')"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/installers.yml
|
||||
|
||||
publish:
|
||||
name: create the release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
# The one job that needs it: creating a release writes to the repository.
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
# Every platform's artifact, flattened into one directory of files to attach.
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Gather the binaries and their checksums
|
||||
id: gather
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -type f -exec mv {} release/ \;
|
||||
ls -lh release/
|
||||
# One combined checksum file is easier to verify against than five loose ones.
|
||||
(cd release && cat *.sha256 > SHA256SUMS && rm -f *.sha256)
|
||||
tag="${{ inputs.tag || github.ref_name }}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Take the release notes from the changelog
|
||||
id: notes
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.gather.outputs.version }}
|
||||
run: |
|
||||
# The section for this version, between its own heading and the next one. If the
|
||||
# changelog has nothing for it, say so plainly rather than inventing notes.
|
||||
if awk -v v="$VERSION" '
|
||||
$0 ~ "^## \\[" v "\\]" {found=1; next}
|
||||
found && /^## \[/ {exit}
|
||||
found {print}
|
||||
' CHANGELOG.md | sed '/^$/d' | head -1 | grep -q .; then
|
||||
awk -v v="$VERSION" '
|
||||
$0 ~ "^## \\[" v "\\]" {found=1; next}
|
||||
found && /^## \[/ {exit}
|
||||
found {print}
|
||||
' CHANGELOG.md > notes.md
|
||||
else
|
||||
echo "No changelog entry for $VERSION." > notes.md
|
||||
fi
|
||||
echo "--- notes ---"; cat notes.md
|
||||
|
||||
- name: Create it
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.gather.outputs.tag }}
|
||||
PRERELEASE: ${{ inputs.prerelease && '--prerelease' || '' }}
|
||||
run: |
|
||||
gh release create "$TAG" release/* \
|
||||
--title "MeshTerm ${TAG#v}" \
|
||||
--notes-file notes.md \
|
||||
--verify-tag \
|
||||
$PRERELEASE
|
||||
echo "https://github.com/${{ github.repository }}/releases/tag/$TAG"
|
||||
@@ -0,0 +1,109 @@
|
||||
# Standalone builds — a single file you download and run, with no Python installed.
|
||||
#
|
||||
# For most people who want MeshTerm, this is the install. They have a radio and a
|
||||
# terminal, not a Python toolchain.
|
||||
#
|
||||
# Frozen builds cannot be cross-compiled: each one is made on the system it targets, so
|
||||
# the matrix is the point of this file rather than an optimisation. Everything here
|
||||
# uploads artifacts and nothing publishes — attaching them to a release stays a separate,
|
||||
# deliberate act.
|
||||
name: Installers
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# Callable, so the release workflow builds these binaries rather than keeping a second
|
||||
# copy of the matrix that would drift from this one.
|
||||
workflow_call:
|
||||
# Only when the packaging itself changes. Three operating systems is not something to
|
||||
# spend on every commit while the repository is private and minutes are metered.
|
||||
push:
|
||||
# `main` only. Without this, every Dependabot branch that touches this file — which
|
||||
# is every action bump — starts a five-platform build, and three of those legs are
|
||||
# billed at 2x and 10x.
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packaging/**"
|
||||
- ".github/workflows/installers.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: installers-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.label }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: windows-latest, label: windows-x64, ext: ".exe" }
|
||||
- { os: macos-latest, label: macos-arm64, ext: "" }
|
||||
# Apple silicon builds do not run on Intel Macs, and plenty of people still
|
||||
# have one. `macos-15-intel` rather than `-26-intel` for the same reason the
|
||||
# Python here is 3.10: built against an older system, runs on more machines.
|
||||
# (Not `macos-13` — that image is retired, and a job asking for it does not
|
||||
# fail, it queues forever and holds the whole run open behind it.)
|
||||
- { os: macos-15-intel, label: macos-x64, ext: "" }
|
||||
- { os: ubuntu-22.04, label: linux-x64, ext: "" }
|
||||
# For the uConsole and other 64-bit ARM handhelds. ARM runners are free on
|
||||
# public repositories; on a private one this leg may sit unclaimed until the
|
||||
# repo is public. Drop the line if it blocks you.
|
||||
- { os: ubuntu-22.04-arm, label: linux-arm64, ext: "" }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
# Oldest supported version on purpose: a binary built against an older glibc
|
||||
# and an older Python runs on more machines than one built against the newest.
|
||||
python-version: "3.10"
|
||||
|
||||
- run: pip install . pyinstaller
|
||||
|
||||
- name: Build
|
||||
working-directory: packaging
|
||||
run: pyinstaller --clean --noconfirm meshterm.spec
|
||||
|
||||
- name: Check it actually runs
|
||||
shell: bash
|
||||
run: |
|
||||
bin="packaging/dist/meshterm${{ matrix.ext }}"
|
||||
chmod +x "$bin" || true
|
||||
# Run it from somewhere else entirely, so a build that only works next to the
|
||||
# source tree fails here rather than on someone's laptop.
|
||||
cd "$(mktemp -d)"
|
||||
"$GITHUB_WORKSPACE/$bin" --help > /dev/null
|
||||
# Reads the asset files, so a bundle missing its pages fails here.
|
||||
"$GITHUB_WORKSPACE/$bin" specimen > /dev/null
|
||||
# And a tool-provided subcommand, which is the check that matters. Every tool is
|
||||
# found at runtime by `pkgutil.iter_modules`, so a bundle that omits them still
|
||||
# starts, still prints help, still draws the specimen — and has an empty menu
|
||||
# with nothing in it but Quit. `--help` passing proves almost nothing on its own.
|
||||
"$GITHUB_WORKSPACE/$bin" contacts --help > /dev/null
|
||||
echo "runs, finds its assets, and its tools registered"
|
||||
|
||||
- name: Name it after the version and the platform
|
||||
shell: bash
|
||||
run: |
|
||||
version=$(python -c "import meshterm; print(meshterm.__version__)")
|
||||
out="meshterm-${version}-${{ matrix.label }}${{ matrix.ext }}"
|
||||
mv "packaging/dist/meshterm${{ matrix.ext }}" "$out"
|
||||
# A checksum beside the binary, so anyone can tell they got what we built.
|
||||
if command -v sha256sum > /dev/null; then
|
||||
sha256sum "$out" > "$out.sha256"
|
||||
else
|
||||
shasum -a 256 "$out" > "$out.sha256"
|
||||
fi
|
||||
cat "$out.sha256"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: meshterm-${{ matrix.label }}
|
||||
path: |
|
||||
meshterm-*
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,99 @@
|
||||
# Publishing to PyPI. Deliberately hard to fire.
|
||||
#
|
||||
# There is no `push: tags` trigger here, and that absence is the point. A release
|
||||
# workflow that publishes on a `v*` tag would mean tagging v0.9.0 on launch day
|
||||
# silently claims a distribution name on a public index, permanently, as a side
|
||||
# effect of an unrelated action. PyPI has no rename and no true delete, so that
|
||||
# side effect is not one you can take back.
|
||||
#
|
||||
# So: a human runs this, from the Actions tab, on purpose, and types the version
|
||||
# to prove they meant this version. Three gates before anything leaves the runner:
|
||||
#
|
||||
# 1. no automatic trigger at all
|
||||
# 2. the typed version must equal the version actually built
|
||||
# 3. the `pypi` environment, which can require a reviewer in repo settings
|
||||
#
|
||||
# It publishes whatever version is in the tree. There is no version argument, so
|
||||
# this cannot publish something other than what is committed.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_version:
|
||||
description: "Version to publish, exactly (e.g. 0.9.0). A mismatch aborts."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build and check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- run: pip install build twine
|
||||
|
||||
- run: python -m build
|
||||
|
||||
# Renders the README the way PyPI will, and fails on metadata PyPI would reject.
|
||||
- run: twine check dist/*
|
||||
|
||||
# The version the build actually produced, read off the artifact rather than
|
||||
# trusted from anywhere else.
|
||||
- id: built
|
||||
shell: bash
|
||||
run: |
|
||||
version=$(ls dist/*.tar.gz | sed -E 's/.*-([0-9][^-]*)\.tar\.gz/\1/')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "built version: $version"
|
||||
|
||||
- name: Confirm the typed version matches what was built
|
||||
shell: bash
|
||||
env:
|
||||
TYPED: ${{ inputs.confirm_version }}
|
||||
BUILT: ${{ steps.built.outputs.version }}
|
||||
run: |
|
||||
if [ "$TYPED" != "$BUILT" ]; then
|
||||
echo "::error::You typed '$TYPED' but the tree builds '$BUILT'. Nothing published."
|
||||
exit 1
|
||||
fi
|
||||
echo "confirmed: $BUILT"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
publish:
|
||||
name: publish to PyPI
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Configure this environment in repo settings with a required reviewer, and it
|
||||
# becomes a fourth gate: the job waits for an approval before it runs. The name
|
||||
# must also match the environment set on the PyPI trusted publisher, or the OIDC
|
||||
# exchange fails with an unhelpful error. If the publisher has no environment
|
||||
# configured, delete this line.
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/mesh-term
|
||||
permissions:
|
||||
# Trusted Publishing mints a short-lived OIDC token instead of storing an API
|
||||
# token in the repository. This is the only thing that needs write here.
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
@@ -14,6 +14,10 @@ dist/
|
||||
.claude/settings.local.json
|
||||
.claude/plans/
|
||||
.claude/housekeeping.md
|
||||
# ...and except the GitHub configuration, which only works where GitHub looks for
|
||||
# it. The catch-all above hid it silently: a workflow that is never committed is
|
||||
# indistinguishable from one that never runs.
|
||||
!.github/
|
||||
|
||||
# Other virtual environments
|
||||
venv/
|
||||
|
||||
+213
-2
@@ -7,8 +7,213 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — wi
|
||||
SemVer itself makes for a leading zero: while the major version is `0`, a **minor** bump is
|
||||
allowed to change behaviour, not just add to it.
|
||||
|
||||
<!--
|
||||
At 0.9.0 — the first public release — everything below collapses into a single entry
|
||||
reading "first public release", with the headline features under it. Nobody arriving at
|
||||
a project on its launch day wants a changelog of the fortnight before it: the versions
|
||||
under 0.9.0 were never released anywhere, and their entries are notes to ourselves about
|
||||
getting ready. Keep the dates and the tags; replace the prose.
|
||||
-->
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.7] — 2026-09-07
|
||||
|
||||
### Changed
|
||||
|
||||
- **MeshTerm moves to Windows Terminal rather than asking to.** It still says so, and
|
||||
still tells you how to stop it, but it doesn't put the question. The classic console
|
||||
can't draw a single icon whatever font it's given, so there was only one sensible
|
||||
answer — and it was being asked of someone who hadn't seen the app yet and had nothing
|
||||
to judge it by.
|
||||
|
||||
Installing a **font** still asks, because that writes a file to your machine. Moving
|
||||
window doesn't. Preferences → Display → Console setup stops both.
|
||||
|
||||
## [0.2.6] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Accepting "reopen in Windows Terminal" killed the session** in the downloadable
|
||||
builds. A new window opened, printed `Security validation failure: parent process has
|
||||
different executable!`, and exited — while the window that made the offer had already
|
||||
closed. Worse than never offering.
|
||||
|
||||
A one-file build unpacks itself and re-runs itself, the two halves coordinating through
|
||||
private environment variables. Those were being handed to the new copy, which concluded
|
||||
it was the second half of a launch it never made, checked that its parent was the same
|
||||
program, found Windows Terminal instead, and stopped. They are stripped now.
|
||||
|
||||
It worked perfectly when run from source, which is the whole lesson: the packaged build
|
||||
is its own platform.
|
||||
|
||||
## [0.2.5] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Every icon was a box in the classic Windows console.** That console — which is what a
|
||||
double-clicked build still gets — draws no emoji at all, whatever font it is set to, so
|
||||
the menu came up as rows of replacement characters.
|
||||
|
||||
MeshTerm now works out which console it is running in and, on that one, draws its icons
|
||||
through the compact single-character table it already keeps for the PicoCalc: `☼` for an
|
||||
advert, `¶` for a message, `★` for the trophy case. Nothing else about the desktop
|
||||
layout changes — same width, same colours, same accented names. Running in Windows
|
||||
Terminal or VS Code's terminal keeps the emoji, and `MESHTERM_EMOJI=1` or `=0` forces
|
||||
the matter either way. `meshterm platform` reports which it chose and why.
|
||||
|
||||
### Added
|
||||
|
||||
- **MeshTerm offers to reopen itself in Windows Terminal.** The classic Windows console —
|
||||
what you get from a double-click — can't show the emoji icons at all, whatever font it
|
||||
is given: the only two Windows fonts with emoji in them are proportional, and a console
|
||||
won't take one. A terminal is the only thing that fixes it, so MeshTerm now offers to
|
||||
move there. One keypress, nothing installed, nothing changed — and you get the app as
|
||||
it was drawn: icons, charts, path chips and all.
|
||||
|
||||
- **MeshTerm can install a font its charts will actually draw in** — for anyone who stays
|
||||
in the classic console. That console does no font fallback at all, and none of the fonts
|
||||
it offers can draw braille, which every timeline and activity graph in MeshTerm is made
|
||||
of. Neither can Hack Nerd Font, JetBrains Mono, Fira Code, Source Code Pro, or even
|
||||
DejaVu Sans Mono; the usual advice works only because most terminals quietly fall back
|
||||
to a proportional font.
|
||||
|
||||
So MeshTerm now ships Cascadia Mono PL — Microsoft's console font, SIL OFL — and on
|
||||
that console offers to use it. If you already have Cascadia (every Windows 11 machine,
|
||||
and anyone with Windows Terminal) it just switches to it. If not, it offers to install
|
||||
it for you: no administrator rights, nothing downloaded, 723 KB. Saying no explains
|
||||
what saying no looks like and asks once more; a second no is remembered and never
|
||||
raised again. Preferences → Display → Console setup changes your mind about either
|
||||
offer.
|
||||
|
||||
## [0.2.4] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MeshTerm crashed on startup on Windows.** Building the screen raised
|
||||
`ctypes.ArgumentError: expected LP__CSBI instance instead of pointer to
|
||||
CONSOLE_SCREEN_BUFFER_INFO`, and on a double-clicked build the traceback and the window
|
||||
it was printed in were destroyed together.
|
||||
|
||||
`ctypes.windll.kernel32` is a cache: every caller in a process gets the same object, and
|
||||
each function on it is a shared attribute. The emoji-width probe declared
|
||||
`GetConsoleScreenBufferInfo` as taking a pointer to *its* copy of the screen-buffer
|
||||
struct — which rewrote the signature for prompt_toolkit, which then called that same
|
||||
function with a pointer to *its* copy. Same layout, different class; ctypes refused.
|
||||
|
||||
Nothing in MeshTerm reaches for `ctypes.windll` any more. Five call sites now take
|
||||
private handles from `core/win32dll.py`, so a signature declared for one purpose is
|
||||
invisible to everything else in the process.
|
||||
|
||||
### Changed
|
||||
|
||||
- The log-detail preference offers the plain level names — Error, Warning, Info, Debug —
|
||||
rather than descriptions of them. Someone being talked through a problem is told "set it
|
||||
to debug", not "set it to everything".
|
||||
|
||||
## [0.2.3] — 2026-09-07
|
||||
|
||||
### Changed
|
||||
|
||||
- The log is a plain text file, `~/.meshterm/meshterm.log`, one readable line per record
|
||||
with the traceback under the line that raised it. It was JSON Lines on the theory that
|
||||
something would replay it; nothing ever did, and the job it actually has is being opened
|
||||
by a person who has hit a problem.
|
||||
- It rotates, at 2 MB across three files. It used to grow without limit — a real one
|
||||
reached 11 MB in ten weeks, which is a lot of disk on a handheld and an unreasonable
|
||||
thing to attach to an issue.
|
||||
- **How much it keeps is a preference** — `log_level`, under a new Diagnostics group,
|
||||
defaulting to `WARNING`. The file now holds the problems rather than a narration of a
|
||||
working session: of the last twenty thousand records in a real log, twelve were a
|
||||
warning or an error.
|
||||
|
||||
### Added
|
||||
|
||||
- One interactive MeshTerm per data directory. A second one is turned away with the way
|
||||
out in the message, rather than quietly overwriting the first one's contacts and
|
||||
settings. The lock is held by the operating system, so a crash releases it — a process
|
||||
id in a file would leave a lock nobody could explain.
|
||||
- Failures are written down. Every unhandled error reaches the log with its traceback and
|
||||
the terminal says where to find it; expected errors go in as warnings, because a log
|
||||
that only holds crashes cannot answer "what happened just before". The bug report form
|
||||
asks for the file.
|
||||
- The window stays open after a crash when MeshTerm owns it. Double-clicking the
|
||||
executable on Windows gives the process its own console, which is destroyed the instant
|
||||
the process ends — so an error was drawn and erased together and all anyone saw was a
|
||||
flash. It now waits for a keypress, but only on that path: a run from a shell leaves its
|
||||
output on screen already, and a successful run should not make anyone press a key.
|
||||
- A readable answer when there is no Windows console. Git Bash, MSYS and Cygwin are not
|
||||
Windows consoles, and prompt_toolkit's own error says so in the language of its
|
||||
internals, arriving as a traceback that reads like a broken app rather than the
|
||||
instruction it is.
|
||||
|
||||
### Fixed
|
||||
|
||||
- All ten stores write through one helper, and the file each writes to first is named
|
||||
for the process writing it. They shared a fixed `.tmp` name, so two copies of MeshTerm
|
||||
saving at the same moment took turns inside one scratch file and then each renamed
|
||||
whatever was in it over the real one. The rename was always atomic; what was being
|
||||
renamed was not.
|
||||
|
||||
## [0.2.2] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The downloadable builds were missing every tool.** They started, printed help and drew
|
||||
the specimen card, and had a menu with nothing in it but Quit — no contacts, no map, no
|
||||
trace, no chat. Tools are found at runtime with `pkgutil.iter_modules`, which finds
|
||||
nothing inside a frozen bundle, so none of them registered. The packaging now collects
|
||||
them, and the smoke tests run a tool-provided subcommand instead of only `--help`, which
|
||||
a gutted build passes perfectly well.
|
||||
|
||||
### Added
|
||||
|
||||
- `MESHTERM_HOME` points config and data somewhere other than `~/.meshterm`. Every copy of
|
||||
MeshTerm shared one directory, so trying a downloaded build meant letting it open the
|
||||
same history database as the one you use — the valuable half of an install, with no way
|
||||
to keep a second copy away from it. Also useful for a portable install, or two radios
|
||||
kept apart.
|
||||
|
||||
## [0.2.1] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- The formatter is pinned. `ruff>=0.4` meant CI installed a newer ruff than a laptop had,
|
||||
and the two disagreed about whether the tree was clean — 0.16 formats Python blocks
|
||||
inside Markdown and 0.15 doesn't, so the first CI run went red on a file that was fine
|
||||
locally. An unpinned formatter isn't a gate.
|
||||
- `docs/` is out of ruff's reach. The survey documents quote code as it looked on a past
|
||||
date, in fragments that were never whole programs; a formatter rewriting a quotation
|
||||
edits the record.
|
||||
- The installer builds only run for `main`. Every Dependabot branch that bumped an action
|
||||
touched the workflow file, matched its path filter, and started a five-platform build.
|
||||
|
||||
### Changed
|
||||
|
||||
- The distribution is named `mesh-term`. PyPI prohibits `meshterm` (pypi/support#12162),
|
||||
and the name you install is the only thing that changes: the import package and the
|
||||
command are both still `meshterm`.
|
||||
- `pyproject.toml` carries the metadata a stranger sees on a package page — project URLs,
|
||||
trove classifiers, and the licence as a PEP 639 expression.
|
||||
|
||||
### Added
|
||||
|
||||
- Continuous integration. Lint once, tests across seven OS and Python combinations, and a
|
||||
packaging job that installs the built wheel into a clean environment and runs the command
|
||||
from outside the source tree.
|
||||
- A release workflow with no automatic trigger, which publishes nothing until a human runs
|
||||
it on purpose and types the version.
|
||||
- A security policy, a code of conduct, issue and pull request templates, weekly
|
||||
dependency updates, and the funding links the app's own support page already offers.
|
||||
- A Releases page. Pushing a version tag builds the binaries and attaches them, with a
|
||||
combined `SHA256SUMS` and release notes taken from this file. It stays inside GitHub
|
||||
and is not the PyPI publish, which remains a separate and deliberate act.
|
||||
- Standalone builds. A PyInstaller spec and a workflow that builds a single runnable
|
||||
file for Windows, macOS (Intel and Apple silicon) and Linux (x64 and ARM64), checks
|
||||
each one runs from outside the source tree, and writes a checksum beside it. Nothing
|
||||
is published; the files are workflow artifacts until someone attaches them to a
|
||||
release.
|
||||
|
||||
## [0.2.0] — 2026-09-07
|
||||
|
||||
The first version to carry a changelog. Everything before it is in the commit log and is
|
||||
@@ -31,6 +236,8 @@ deliberately not reconstructed here.
|
||||
which is the only handle a tile operator has on a client.
|
||||
- `macOS` and `Bluetooth` are spelled one way across the project. `BLE` stays where it is
|
||||
an identifier — the transport name, the config keys, the CLI.
|
||||
- `meshterm --help` opens with the same description as the package summary and the
|
||||
PyPI page, instead of a fourth wording of its own.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -41,5 +248,9 @@ deliberately not reconstructed here.
|
||||
- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that
|
||||
happened never to run.
|
||||
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.2.0...HEAD
|
||||
[0.2.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.0
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.2.4...HEAD
|
||||
[0.2.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.4
|
||||
[0.2.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.3
|
||||
[0.2.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.2
|
||||
[0.2.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.1
|
||||
[0.2.0]: https://github.com/jpmartineau/MeshTerm/tree/v0.2.0
|
||||
@@ -0,0 +1,83 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at johnputer@meshterm.net. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -22,3 +22,12 @@ project and its original author. A fork that keeps soliciting
|
||||
donations through that link, or through a lookalike of it, without
|
||||
clearly redirecting them to itself, is not affiliated with this
|
||||
project and is not authorized to do so.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Bundled font: meshterm/assets/fonts/CascadiaMonoPL.ttf is Cascadia Mono PL,
|
||||
Copyright (c) 2019 - Present, Microsoft Corporation, with Reserved Font Name
|
||||
Cascadia Code. It is redistributed here unmodified under the SIL Open Font
|
||||
License, Version 1.1, which is included beside it as CascadiaMono-OFL.txt and
|
||||
is its only license -- the Apache License above does not apply to it. The
|
||||
inclusion of this font does not imply that Microsoft endorses MeshTerm.
|
||||
@@ -14,11 +14,18 @@ radio overhears is recorded to a local SQLite database, so the longer you run it
|
||||
your mesh's history is worth.
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/jpmartineau/MeshTerm/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/jpmartineau/MeshTerm/actions/workflows/ci.yml/badge.svg"></a>
|
||||
<img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-blue">
|
||||
<a href="LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/license-Apache--2.0-green"></a>
|
||||
<img alt="For MeshCore" src="https://img.shields.io/badge/for-MeshCore-8A2BE2">
|
||||
<img alt="Interface: TUI + CLI" src="https://img.shields.io/badge/interface-TUI%20%2B%20CLI-orange">
|
||||
<a href="https://github.com/jpmartineau/MeshTerm/releases/latest"><img alt="Download" src="https://img.shields.io/badge/download-latest-brightgreen"></a>
|
||||
<a href="https://discord.gg/AZwe5Uvb3S"><img alt="Discord" src="https://img.shields.io/badge/chat-Discord-5865F2"></a>
|
||||
</p>
|
||||
|
||||
> MeshTerm is a side project, not a product. Bug reports are very welcome — but please
|
||||
> open an issue before writing a pull request. [More on how it's run](#how-this-project-is-run).
|
||||
|
||||
<!--
|
||||
Screenshots welcome here. Drop PNGs into docs/ and reference them, e.g.:
|
||||

|
||||
@@ -48,19 +55,111 @@ your mesh's history is worth.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone <your-fork-url> meshterm
|
||||
cd meshterm
|
||||
pip install -e ".[dev]"
|
||||
### Download a build
|
||||
|
||||
**[⬇ Latest release](https://github.com/jpmartineau/MeshTerm/releases/latest)** — one file,
|
||||
no Python needed. Windows, macOS (Intel and Apple silicon), and Linux (x64 and ARM64, so
|
||||
the uConsole is covered).
|
||||
|
||||
MeshTerm is a terminal program, so **open a terminal and run it from there.** You can
|
||||
double-click it and it will work, but you'll get whatever console your system picks, and
|
||||
if anything goes wrong at startup the window closes before you can read why.
|
||||
|
||||
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
|
||||
|
||||
```powershell
|
||||
.\meshterm-0.2.7-windows-x64.exe
|
||||
```
|
||||
|
||||
Python **3.10+** is required (matching the `meshcore` library). The `[dev]` extra adds
|
||||
pytest and ruff; drop it for a runtime-only install (`pip install -e .`).
|
||||
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
|
||||
|
||||
```bash
|
||||
python -m pytest -q # run the test suite
|
||||
chmod +x meshterm-0.2.7-*
|
||||
./meshterm-0.2.7-*
|
||||
```
|
||||
|
||||
### A word about terminals, on Windows
|
||||
|
||||
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether you
|
||||
see them is up to your *terminal*, not really your font: a modern terminal, asked for a
|
||||
character its font doesn't have, quietly borrows it from another font on the machine.
|
||||
That's why the app looks right in Windows Terminal, in VS Code's terminal, and on macOS
|
||||
and Linux — usually with a font that contains almost none of it.
|
||||
|
||||
The classic Windows console — the black `cmd.exe` window you get from a double-click —
|
||||
doesn't borrow. It draws what its one font holds and empty boxes for everything else, and
|
||||
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
|
||||
are proportional, and a console won't take a proportional font.
|
||||
|
||||
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
|
||||
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
|
||||
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
|
||||
already on every Windows 11 machine and is a free install on Windows 10.
|
||||
|
||||
Where there's no Windows Terminal to move to, MeshTerm offers the next best thing instead
|
||||
— the charts and marks, without the icons. It ships
|
||||
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
|
||||
font and one of the very few monospace faces that carries braille at all, and will install
|
||||
it just for you: no administrator rights, nothing downloaded. If you already have Cascadia
|
||||
it simply switches to it.
|
||||
|
||||
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
|
||||
|
||||
Rename it to something you don't mind typing and put it somewhere on your `PATH`, and it
|
||||
becomes just `meshterm` from anywhere.
|
||||
|
||||
> **Your system will complain the first time, and it's right to.** These builds aren't
|
||||
> code-signed — signing costs real money on both platforms and this is a free side
|
||||
> project.
|
||||
>
|
||||
> - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with
|
||||
> `xattr -d com.apple.quarantine meshterm-0.2.7-*` and run it again.
|
||||
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
|
||||
>
|
||||
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
|
||||
|
||||
> **Trying a build without touching your real data.** MeshTerm keeps everything in
|
||||
> `~/.meshterm` — your history database, contacts, channel keys — and *every* copy of
|
||||
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
|
||||
>
|
||||
> ```bash
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.7-* # macOS, Linux
|
||||
> ```
|
||||
> ```powershell
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.7-windows-x64.exe
|
||||
> ```
|
||||
>
|
||||
> Same variable if you run two radios and want them kept apart.
|
||||
|
||||
### Or install with pip
|
||||
|
||||
You need **Python 3.10 or newer**. That's the only requirement — MeshTerm pulls in
|
||||
everything else itself.
|
||||
|
||||
```bash
|
||||
pip install git+https://github.com/jpmartineau/MeshTerm
|
||||
meshterm
|
||||
```
|
||||
|
||||
`meshterm` opens the full-screen menu. That's the whole install.
|
||||
|
||||
**Rather not touch your system Python?** [pipx](https://pipx.pypa.io/) puts the app in its
|
||||
own private environment and still gives you a plain `meshterm` command:
|
||||
|
||||
```bash
|
||||
pipx install git+https://github.com/jpmartineau/MeshTerm
|
||||
```
|
||||
|
||||
**No radio yet?** `meshterm --mock` runs the entire app against a simulated mesh, so you
|
||||
can have a look around before you buy anything.
|
||||
|
||||
### Coming
|
||||
|
||||
**`pip install mesh-term`**, once the package is published. It isn't yet — the name
|
||||
question is still being sorted out ([why](https://github.com/pypi/support/issues/12162)).
|
||||
|
||||
Setting up for development instead? That's in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
@@ -253,6 +352,41 @@ Adding a feature means subclassing `Tool`, decorating it with `@register`, and
|
||||
implementing `run()`. The same registry builds both the CLI and the menu, and the base
|
||||
class wraps every execution in a logged `runs` row automatically.
|
||||
|
||||
## How this project is run
|
||||
|
||||
MeshTerm is one person working evenings and weekends. I'd rather tell you that up front
|
||||
than have you guess from how long things take.
|
||||
|
||||
**Issues are welcome — all of them.** Bugs, questions, "is this supposed to do that". The
|
||||
[bug form](.github/ISSUE_TEMPLATE/bug.yml) asks for a fair bit, and that's on purpose: how
|
||||
well a problem is described really does decide whether I can do anything with it. If I can
|
||||
reproduce it, I'll usually chase it. If I can't, I'm mostly guessing.
|
||||
|
||||
**Ask before you write a pull request.** Open an issue first and wait for a yes. I'm not
|
||||
being precious — MeshTerm has firm house rules about how screens get built (they're in
|
||||
[CLAUDE.md](CLAUDE.md)), and I'd hate for you to spend a weekend on something I then ask
|
||||
you to rewrite. A quick conversation first saves us both.
|
||||
|
||||
**I can't promise timelines.** Some things get fixed the same night. Some sit for a month
|
||||
because life happened. If your issue goes quiet, a nudge is completely fine — it's not
|
||||
rude, it's helpful.
|
||||
|
||||
**Where to report things.** Either works:
|
||||
|
||||
- **[GitHub issues](https://github.com/jpmartineau/MeshTerm/issues)** if you have an
|
||||
account. This is where things get tracked and fixed, so it's the shortest path.
|
||||
- **[Discord](https://discord.gg/AZwe5Uvb3S)** if you don't, or if you're not sure it's a
|
||||
bug yet. Ask in the support forum, or drop reproducible defects in `#bugs`. I move the
|
||||
real ones over to GitHub myself.
|
||||
|
||||
Don't worry about picking the wrong one. Getting told about a problem beats filing it
|
||||
tidily.
|
||||
|
||||
Also here: [CONTRIBUTING.md](CONTRIBUTING.md) ·
|
||||
[SECURITY.md](SECURITY.md) ·
|
||||
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
|
||||
[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -279,6 +413,15 @@ tiles by [OpenFreeMap](https://openfreemap.org/). OpenStreetMap data is availabl
|
||||
with a small built-in reader for the [Mapbox Vector Tile](https://github.com/mapbox/vector-tile-spec)
|
||||
format.
|
||||
|
||||
### Bundled font
|
||||
|
||||
MeshTerm ships **Cascadia Mono PL**, © 2019–present Microsoft Corporation, redistributed
|
||||
unmodified under the [SIL Open Font License 1.1](meshterm/assets/fonts/CascadiaMono-OFL.txt).
|
||||
It's offered to Windows users whose console can't draw the charts — see
|
||||
[A word about fonts](#a-word-about-fonts). Microsoft doesn't endorse MeshTerm; the font is
|
||||
simply the right tool, being one of the very few monospace faces that carries the braille
|
||||
block the timelines are drawn from.
|
||||
|
||||
### Open-source dependencies
|
||||
|
||||
MeshTerm is built with these libraries; each is used under its own license (see the
|
||||
@@ -307,3 +450,9 @@ license (see [NOTICE](NOTICE)); a redistributed fork should go by its own name.
|
||||
donate link built into the app supports this project and its original author —
|
||||
forks that keep soliciting through it without redirecting it to themselves aren't
|
||||
affiliated with this project.
|
||||
|
||||
The bundled font is **not** covered by that license. `meshterm/assets/fonts/CascadiaMonoPL.ttf`
|
||||
is Microsoft's Cascadia Mono PL, redistributed unmodified under the SIL Open Font License
|
||||
1.1, which travels with it as
|
||||
[CascadiaMono-OFL.txt](meshterm/assets/fonts/CascadiaMono-OFL.txt) and stays its only
|
||||
license.
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Use GitHub's private vulnerability reporting: go to the **Security** tab of this
|
||||
repository and choose **Report a vulnerability**. That opens a private thread visible only
|
||||
to you and the maintainer, so nothing is disclosed while it is being fixed.
|
||||
|
||||
If that is unavailable to you for any reason, email <johnputer@meshterm.net> instead.
|
||||
|
||||
**Please do not open a public issue for a security problem.**
|
||||
|
||||
## What to expect
|
||||
|
||||
MeshTerm is maintained by one person in his spare time, so this promises only what can
|
||||
actually be kept:
|
||||
|
||||
- **An acknowledgement within a week.** Not a fix within a week — an answer saying the
|
||||
report was read and what happens next.
|
||||
- **A fix or a decision within 90 days** for anything confirmed. If it will take longer,
|
||||
you will be told why.
|
||||
- **Credit in the release notes**, unless you would rather not be named.
|
||||
|
||||
A slow reply is not a dismissal. If a week passes with silence, a nudge on the same thread
|
||||
is welcome.
|
||||
|
||||
## Supported versions
|
||||
|
||||
While the major version is `0`, only the **latest release** is supported. There are no
|
||||
backports; a fix ships in the next version.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope — anything in this repository:
|
||||
|
||||
- Code execution, path traversal, or file overwrite triggered by data MeshTerm reads:
|
||||
packets overheard from the mesh, a companion's responses, a config or preferences file,
|
||||
a downloaded map tile.
|
||||
- Anything that discloses a **channel key**, a **repeater admin password**, or a node's
|
||||
private key material — including into the local database, a log, an export, or the
|
||||
screen when it should not be there.
|
||||
- Anything that lets a crafted packet corrupt or delete the observation database.
|
||||
- Weaknesses in how MeshTerm stores credentials on disk.
|
||||
|
||||
Out of scope, though still worth telling us about somewhere:
|
||||
|
||||
- Vulnerabilities in the **MeshCore firmware** or in the companion device itself. Those
|
||||
belong upstream at the MeshCore project.
|
||||
- Vulnerabilities in the `meshcore` Python library, `bleak`, or any other dependency —
|
||||
report those to their maintainers. If a MeshTerm change can mitigate one, we want to
|
||||
know.
|
||||
- The fact that anyone within radio range can hear a public channel. That is how a mesh
|
||||
works, not a defect.
|
||||
- Anything requiring an attacker to already have write access to your filesystem or your
|
||||
companion device.
|
||||
|
||||
## A note on what MeshTerm handles
|
||||
|
||||
MeshTerm decrypts channel traffic using keys **you** give it, and it records everything it
|
||||
overhears to a local SQLite database. That database is not encrypted. Treat it as you would
|
||||
any file holding your mesh's history, and remember that a backup of it carries the same
|
||||
weight.
|
||||
@@ -1,5 +1,10 @@
|
||||
# Navigation survey — every exception to "Esc pops the stack"
|
||||
|
||||
> **A historical record.** This was a snapshot of the code on the date below, kept
|
||||
> because a project that writes down its own problems is easier to trust than one
|
||||
> that doesn't. Some of what's here has since been fixed and some hasn't; nothing in
|
||||
> it is a to-do list. Read it as "what this looked like then", not "what is wrong now".
|
||||
|
||||
Written 2026-08-22 against `main` @ `4b29248`. A catalogue, not a change: nothing here had
|
||||
been fixed at the time of writing. Each finding says where it is, what it does, and what
|
||||
makes it a question.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# UI consistency survey — what is off, and what turned out to be fine
|
||||
|
||||
> **A historical record.** This was a snapshot of the code on the date below, kept
|
||||
> because a project that writes down its own problems is easier to trust than one
|
||||
> that doesn't. Some of what's here has since been fixed and some hasn't; nothing in
|
||||
> it is a to-do list. Read it as "what this looked like then", not "what is wrong now".
|
||||
|
||||
Written 2026-08-22 against `main` @ `4b29248`. Companion to
|
||||
[`survey-navigation.md`](survey-navigation.md), which covers navigation exceptions; this one
|
||||
covers everything else in the CLAUDE.md UX standard. A catalogue, not a change.
|
||||
|
||||
@@ -5,7 +5,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.2.7"
|
||||
__author__ = "Jean-Pierre Martineau"
|
||||
|
||||
#: The year MeshTerm was first published; the copyright span starts here.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
Copyright (c) 2019 - Present, Microsoft Corporation,
|
||||
with Reserved Font Name Cascadia Code.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
+368
-18
@@ -10,23 +10,28 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from .context import AppContext
|
||||
from .core import win32dll
|
||||
from .core.admin_store import AdminStore
|
||||
from .core.config import Settings
|
||||
from .core.connection import DeviceCommandError, is_connection_lost
|
||||
from .core.device_config import DeviceConfigError
|
||||
from .core.device_store import DeviceStore
|
||||
from .core.preferences import PreferenceError
|
||||
from .core.instancelock import InstanceBusy, hold_instance_lock
|
||||
from .core.preferences import PreferenceError, Preferences
|
||||
from .core.selection import DeviceSelectionError
|
||||
from .persistence.logging import configure_logging
|
||||
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
|
||||
from .persistence.repository import Repository
|
||||
from .platforms import Resolution, resolve, set_platform
|
||||
from .platforms import Resolution, resolve, set_platform, without_emoji
|
||||
from .tools import all_tools
|
||||
from .tools.base import Tool, ToolResult
|
||||
from .ui.termfont import emoji_support
|
||||
from .ui.theme import make_console
|
||||
|
||||
|
||||
@@ -68,7 +73,13 @@ app = typer.Typer(
|
||||
# section panels flattened to plain headings by _unframe_help_panels() above — a
|
||||
# splash of colour, none of the framing.
|
||||
rich_markup_mode="rich",
|
||||
help="MeshTerm — a modern toolkit for tuning and exploring your MeshCore mesh",
|
||||
# The one description, same as the package summary and the PyPI page. See
|
||||
# `meshterm.__doc__` — if this drifts from that, one of them is lying.
|
||||
help=(
|
||||
"MeshTerm is a full-featured TUI-based MeshCore client for your terminal. "
|
||||
"Supports USB, Bluetooth and TCP companion connection. "
|
||||
"For Windows, macOS and Linux."
|
||||
),
|
||||
)
|
||||
|
||||
# The context built by the callback and consumed by subcommands within one process.
|
||||
@@ -128,16 +139,33 @@ def main_callback(
|
||||
_platform_resolution = resolve(platform)
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(str(exc), param_hint="--platform") from exc
|
||||
set_platform(_platform_resolution.platform)
|
||||
# A terminal that cannot draw emoji is asked about here rather than in resolve(),
|
||||
# which answers "which flavour" from the flag, the env and the device tree and should
|
||||
# stay that pure — this is the *terminal's* limit, and it applies whichever flavour
|
||||
# those three chose. See meshterm.ui.termfont.emoji_support.
|
||||
active = _platform_resolution.platform
|
||||
if not emoji_support().supported:
|
||||
active = without_emoji(active)
|
||||
set_platform(active)
|
||||
|
||||
settings = Settings.load()
|
||||
if db_path is not None:
|
||||
settings.db_path = db_path
|
||||
|
||||
console = make_console()
|
||||
configure_logging(console, settings.config_dir, level=logging.INFO, quiet=quiet or json_output)
|
||||
# Loaded before logging is configured, because how much goes in the file is one of
|
||||
# them — and handed to the context afterwards so the file is not read twice.
|
||||
prefs = Preferences.load(settings.config_dir / "preferences.yaml")
|
||||
configure_logging(
|
||||
console,
|
||||
settings.config_dir,
|
||||
level=logging.INFO,
|
||||
file_level=level_from_name(prefs.log_level),
|
||||
quiet=quiet or json_output,
|
||||
)
|
||||
|
||||
app_ctx = AppContext(
|
||||
preferences=prefs,
|
||||
console=console,
|
||||
settings=settings,
|
||||
repo=Repository(settings.db_path),
|
||||
@@ -160,7 +188,255 @@ def main_callback(
|
||||
if ctx.invoked_subcommand is None:
|
||||
from .ui.menu import run_menu
|
||||
|
||||
asyncio.run(_drive(run_menu(app_ctx), app_ctx))
|
||||
# Before prompt_toolkit takes the screen: this can change the console's own font,
|
||||
# or hand the whole session to a better terminal, and the full-screen frame should
|
||||
# be drawn once, in whatever it ends up in.
|
||||
if _offer_a_console_that_can_draw_meshterm(console, prefs):
|
||||
return
|
||||
|
||||
# The interactive session is the one that holds the stores in memory and rewrites
|
||||
# them whole, so it is the one that claims the directory. One-shot subcommands are
|
||||
# brief and mostly read; blocking `meshterm contacts` because a menu is open in
|
||||
# another window would be an obstacle rather than a guard.
|
||||
try:
|
||||
with hold_instance_lock(settings.config_dir):
|
||||
asyncio.run(_drive(run_menu(app_ctx), app_ctx))
|
||||
except InstanceBusy as exc:
|
||||
console.print(f"[err]✗[/err] {exc}")
|
||||
raise typer.Exit(code=1) from None
|
||||
except Exception as exc:
|
||||
if type(exc).__name__ == "NoConsoleScreenBufferError":
|
||||
# prompt_toolkit needs a real Win32 console and reports its absence in the
|
||||
# language of its own internals. A traceback reads as "this app is broken"
|
||||
# when the answer is "open a different terminal", so answer that instead.
|
||||
get_logger().warning("no Windows console available: %s", exc)
|
||||
_report_no_windows_console(console)
|
||||
raise typer.Exit(code=1) from None
|
||||
# Logged before it is re-raised, and this is the case the log matters most for:
|
||||
# a session launched by double-clicking the executable owns its console window,
|
||||
# so when it dies the window closes with it and takes the traceback along. The
|
||||
# file is the only thing left to read afterwards.
|
||||
get_logger().exception("the interactive session raised an unhandled error")
|
||||
_report_log_location(console, settings.config_dir)
|
||||
raise
|
||||
|
||||
|
||||
def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences) -> bool:
|
||||
"""On the classic Windows console, get the reader somewhere better than it.
|
||||
|
||||
Two remedies, and they are not asked the same way.
|
||||
|
||||
**Windows Terminal** first, and simply done. It is the only thing that gets the whole
|
||||
app — emoji icons included, which no font can put on that console, the only Windows
|
||||
fonts carrying any being proportional and a console taking none of them. Moving there
|
||||
installs nothing and changes nothing, so there is one sensible answer to a question
|
||||
that would be put to someone who has not seen the app yet and cannot judge it.
|
||||
|
||||
**A font** second, only where the move was not possible, and that one *is* asked —
|
||||
it writes a file onto someone's machine, which is theirs to agree to.
|
||||
|
||||
Args:
|
||||
console: The console to report and ask on.
|
||||
prefs: The preference set, read for a previous refusal and written on a new one.
|
||||
|
||||
Returns:
|
||||
``True`` when the session is being reopened elsewhere and this one should stop.
|
||||
"""
|
||||
from .ui.termfont import classic_console
|
||||
|
||||
if not classic_console() or prefs.get("console_setup") == "off":
|
||||
return False
|
||||
if _move_to_windows_terminal(console):
|
||||
return True
|
||||
_offer_a_font_this_console_can_draw(console, prefs)
|
||||
return False
|
||||
|
||||
|
||||
def _move_to_windows_terminal(console: Console) -> bool:
|
||||
"""Reopen the session in Windows Terminal, where the app can draw all of itself.
|
||||
|
||||
Done rather than offered. The classic console cannot show a single icon whatever it is
|
||||
given, so "reopen there?" is a question with one sensible answer, asked of someone who
|
||||
has not seen the app yet and so cannot judge it — and asked at the worst moment, before
|
||||
anything has been drawn. Moving costs nothing and changes nothing: no install, no
|
||||
setting, one window instead of another.
|
||||
|
||||
It is announced rather than silent, and the announcement names the way to stop it,
|
||||
because a program that opens a window you did not ask for should at least say so.
|
||||
``console_setup`` set to ``off`` keeps the session here for good.
|
||||
|
||||
Args:
|
||||
console: The console to report on.
|
||||
|
||||
Returns:
|
||||
Whether Windows Terminal took over and this session should stand down.
|
||||
"""
|
||||
from .core import winterminal
|
||||
|
||||
if winterminal.available() is None:
|
||||
return False
|
||||
|
||||
console.print()
|
||||
console.print("[accent]•[/accent] Opening in [accent]Windows Terminal[/accent] — this console")
|
||||
console.print(" can't draw MeshTerm's icons or charts.")
|
||||
console.print("[muted] Preferences → Display → Console setup keeps it here instead.[/muted]")
|
||||
console.print()
|
||||
|
||||
if winterminal.reopen():
|
||||
get_logger().info("reopened the session in Windows Terminal")
|
||||
return True
|
||||
console.print("[warn]⚠[/warn] Windows Terminal would not start. Staying here.")
|
||||
get_logger().warning("could not start Windows Terminal")
|
||||
return False
|
||||
|
||||
|
||||
def _offer_a_font_this_console_can_draw(console: Console, prefs: Preferences) -> None:
|
||||
"""Offer a font that can at least draw the charts and marks, for a reader who stays.
|
||||
|
||||
The console does no font fallback: whatever its font lacks is a box, and its default
|
||||
— Consolas, or Lucida Console under Windows PowerShell — lacks most of what MeshTerm
|
||||
is drawn with, including every one of the 44 braille cells the timelines are made of.
|
||||
|
||||
Two shapes, depending on what is already there: a machine with Windows Terminal (and
|
||||
every Windows 11 machine) already has Cascadia, so the offer is only to *use* it; a
|
||||
bare Windows 10 gets the offer to install the copy MeshTerm ships.
|
||||
|
||||
Declining is answered with a plain description of what declining looks like, and then
|
||||
asked once more — a reader who has never seen the app has no way to know what "some
|
||||
glyphs may not render" is going to mean. A second decline is remembered, in the
|
||||
``console_setup`` preference, so the question is asked twice in total and never again.
|
||||
|
||||
Args:
|
||||
console: The console to ask on.
|
||||
prefs: The preference set, read for a previous refusal and written on a new one.
|
||||
"""
|
||||
from .core import consolefont
|
||||
from .ui.termfont import face_draws_charts, installed_chart_font
|
||||
|
||||
if face_draws_charts(consolefont.current_face()):
|
||||
return
|
||||
|
||||
installed = installed_chart_font()
|
||||
for asking_again in (False, True):
|
||||
if asking_again:
|
||||
console.print()
|
||||
console.print("[warn]⚠[/warn] Then MeshTerm is going to look wrong here.")
|
||||
console.print()
|
||||
console.print(" Every chart — the signal timelines, the activity graphs — is")
|
||||
console.print(" drawn from braille characters this console's font does not")
|
||||
console.print(" have, so they will come out as rows of empty boxes. So will")
|
||||
console.print(" the ✓ and ✗ marks, and the ★ on the map.")
|
||||
console.print()
|
||||
console.print(" Nothing is broken and nothing is lost — the app works, and")
|
||||
console.print(" this is only about what the font can draw. It is your")
|
||||
console.print(" console, so it is your call.")
|
||||
console.print()
|
||||
elif installed:
|
||||
console.print()
|
||||
console.print("[accent]•[/accent] This console's font can't draw MeshTerm's charts.")
|
||||
console.print(f" You already have [accent]{installed}[/accent], which can.")
|
||||
console.print()
|
||||
else:
|
||||
console.print()
|
||||
console.print("[accent]•[/accent] This console's font can't draw MeshTerm's charts.")
|
||||
console.print(" MeshTerm ships [accent]Cascadia Mono PL[/accent] — Microsoft's")
|
||||
console.print(" console font — and can install it just for you. No")
|
||||
console.print(" administrator rights, nothing downloaded, 723 KB.")
|
||||
console.print()
|
||||
|
||||
verb = "Use it" if installed else "Install and use it"
|
||||
if _asks_yes(console, f" {verb}? [y/N] "):
|
||||
_use_a_better_console_font(console, installed)
|
||||
return
|
||||
|
||||
prefs.set("console_setup", "off")
|
||||
try:
|
||||
prefs.save()
|
||||
except (OSError, RuntimeError) as exc: # a read-only config dir is not fatal here
|
||||
get_logger().warning("could not remember the console setup choice: %s", exc)
|
||||
console.print()
|
||||
console.print("[muted] Leaving it as it is. Change your mind on the Preferences[/muted]")
|
||||
console.print("[muted] page, under Display → Console setup.[/muted]")
|
||||
console.print()
|
||||
|
||||
|
||||
def _use_a_better_console_font(console: Console, installed: str | None) -> None:
|
||||
"""Install (when needed) and select the font, and say plainly what happened."""
|
||||
from .core import consolefont
|
||||
|
||||
face = installed
|
||||
if face is None:
|
||||
if not consolefont.install_bundled_font():
|
||||
console.print("[err]✗[/err] The font could not be installed. Leaving the")
|
||||
console.print(" console as it is.")
|
||||
get_logger().warning("bundled console font could not be installed")
|
||||
return
|
||||
face = consolefont.BUNDLED_FACE
|
||||
|
||||
if consolefont.use(face):
|
||||
console.print(f"[ok]✓[/ok] Now drawing with [accent]{face}[/accent].")
|
||||
get_logger().info("console font set to %s", face)
|
||||
else:
|
||||
# Installed but not selected: the font is there for next time and for the
|
||||
# console's own properties dialog, which is worth saying rather than swallowing.
|
||||
console.print(f"[warn]⚠[/warn] {face} is installed, but this console kept its own")
|
||||
console.print(" font. It will be available the next time you open one.")
|
||||
get_logger().warning("console refused the font %s", face)
|
||||
|
||||
|
||||
def _asks_yes(console: Console, prompt: str, *, default: bool = False) -> bool:
|
||||
"""Ask a yes/no question on the console.
|
||||
|
||||
Not a TUI dialog: this runs before prompt_toolkit owns the screen, alongside the other
|
||||
plain-console reports here. An unanswerable prompt — no stdin, a closed pipe — takes
|
||||
the default, and each caller's default is the answer that costs the reader least.
|
||||
|
||||
Args:
|
||||
console: The console to ask on.
|
||||
prompt: The question, ending in its own spacing.
|
||||
default: The answer an empty line means, and the one an unreadable stdin takes.
|
||||
|
||||
Returns:
|
||||
Whether the reader said yes.
|
||||
"""
|
||||
console.print(prompt, end="")
|
||||
try:
|
||||
answer = input().strip().lower()
|
||||
except (EOFError, KeyboardInterrupt, OSError):
|
||||
console.print()
|
||||
return default
|
||||
if not answer:
|
||||
return default
|
||||
return answer in {"y", "yes"}
|
||||
|
||||
|
||||
def _report_log_location(console: Console, config_dir: Path) -> None:
|
||||
"""Point at the log after a fault, since it is the copy that outlives the terminal."""
|
||||
console.print()
|
||||
console.print(f"[muted]The details are in {log_path(config_dir)}[/muted]")
|
||||
console.print("[muted]Attach it to a bug report. For more detail next time, raise[/muted]")
|
||||
console.print("[muted]'Log detail' on the Preferences page.[/muted]")
|
||||
|
||||
|
||||
def _report_no_windows_console(console: Console) -> None:
|
||||
"""Explain a console MeshTerm cannot draw in, and how to get one it can.
|
||||
|
||||
Reached when prompt_toolkit finds no Win32 console screen buffer: a Git Bash, MSYS or
|
||||
Cygwin shell — which announce themselves as ``xterm`` and are not Windows consoles at
|
||||
all — or a process whose output has been redirected away from one.
|
||||
"""
|
||||
console.print("[err]✗[/err] MeshTerm needs a Windows console, and could not find one.")
|
||||
console.print()
|
||||
console.print("That usually means one of two things:")
|
||||
console.print()
|
||||
console.print(" • You are in Git Bash, MSYS or Cygwin. Those are not Windows consoles.")
|
||||
console.print(" Open [accent]Windows Terminal[/accent] or [accent]PowerShell[/accent]")
|
||||
console.print(" and run it from there.")
|
||||
console.print(" • The output is piped or redirected somewhere. The full-screen menu")
|
||||
console.print(" needs the terminal itself.")
|
||||
console.print()
|
||||
console.print("Every screen also has a subcommand, and those pipe fine —")
|
||||
console.print("try [accent]meshterm --help[/accent].")
|
||||
|
||||
|
||||
@app.command(name="platform")
|
||||
@@ -171,6 +447,10 @@ def platform_command() -> None:
|
||||
flavour a given invocation would run as, and why — the ``--platform``/
|
||||
``MESHTERM_PLATFORM`` inputs, what ``/proc/device-tree/model`` reports (``None`` off
|
||||
the actual hardware), and which of those decided it.
|
||||
|
||||
It also reports the terminal's own emoji verdict, which is a separate question from
|
||||
the flavour and the usual reason a Windows session looks plainer than the screenshots
|
||||
(see :func:`meshterm.ui.termfont.emoji_support`).
|
||||
"""
|
||||
assert _platform_resolution is not None # set by the callback that always runs first
|
||||
r = _platform_resolution
|
||||
@@ -180,6 +460,16 @@ def platform_command() -> None:
|
||||
console.print(f" MESHTERM_PLATFORM: {r.env or '(not set)'}")
|
||||
console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}")
|
||||
|
||||
emoji = emoji_support()
|
||||
verdict = "yes" if emoji.supported else "no — icons use the compact glyphs"
|
||||
console.print(f"icons: [accent]{verdict}[/accent] (source: {emoji.source})")
|
||||
if not emoji.supported and emoji.source == "console":
|
||||
console.print(
|
||||
" this is the classic Windows console, which draws no emoji whatever font\n"
|
||||
" it is set to. Windows Terminal and VS Code's terminal both do — running\n"
|
||||
" MeshTerm in one gets the icons back. MESHTERM_EMOJI=1 overrides this."
|
||||
)
|
||||
|
||||
|
||||
@app.command(name="specimen")
|
||||
def specimen_command() -> None:
|
||||
@@ -212,20 +502,30 @@ def run_tool_command(tool: Tool, params: dict) -> None:
|
||||
asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
|
||||
except (DeviceSelectionError, DeviceConfigError, DeviceCommandError, PreferenceError) as exc:
|
||||
# Expected user-facing error (ambiguous/absent device, a bad config or preference
|
||||
# value, or a transient command failure): show the message, not a traceback.
|
||||
# value, or a transient command failure): show the message, not a traceback. Logged
|
||||
# at warning because it is a thing that went wrong, even though it is an ordinary
|
||||
# one — a log that only holds crashes cannot answer "what happened before it".
|
||||
get_logger().warning("%s failed: %s", tool.name, exc)
|
||||
_state.console.print(f"[err]✗[/err] {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
except Exception as exc:
|
||||
# A dropped serial link (device unplugged/powered off mid-command) can't be recovered
|
||||
# from in a one-shot scripted run the way the interactive menu offers — but it should
|
||||
# still read as a clean message, not a traceback. Anything else propagates as before.
|
||||
if not is_connection_lost(exc):
|
||||
raise
|
||||
_state.console.print(
|
||||
"[err]✗[/err] the connection to your device was lost "
|
||||
"(it may have been unplugged or powered off)."
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
# still read as a clean message, not a traceback.
|
||||
if is_connection_lost(exc):
|
||||
get_logger().warning("%s lost the device connection: %s", tool.name, exc)
|
||||
_state.console.print(
|
||||
"[err]✗[/err] the connection to your device was lost "
|
||||
"(it may have been unplugged or powered off)."
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
# Anything else is a real fault. It still reaches the terminal as a traceback,
|
||||
# because a person looking at one wants to see it — but it also lands in the log,
|
||||
# which is the copy that survives the terminal being closed and is the one thing
|
||||
# worth attaching to a bug report.
|
||||
get_logger().exception("%s raised an unhandled error", tool.name)
|
||||
_report_log_location(_state.console, _state.settings.config_dir)
|
||||
raise
|
||||
|
||||
|
||||
async def _drive(coro, ctx: AppContext) -> None:
|
||||
@@ -274,9 +574,59 @@ def _register_all() -> None:
|
||||
_register_all()
|
||||
|
||||
|
||||
def _owns_its_console() -> bool:
|
||||
"""Whether this process is the only one on its console — i.e. it was double-clicked.
|
||||
|
||||
Windows gives a console to a process launched from Explorer and destroys it the moment
|
||||
that process ends, so an error message is displayed and erased in the same instant.
|
||||
Launched from a shell instead, the console belongs to the shell and survives.
|
||||
|
||||
``GetConsoleProcessList`` tells the two apart: it reports how many processes are
|
||||
attached to this console. One is us, alone, holding a window that dies with us.
|
||||
|
||||
Returns:
|
||||
``True`` only on Windows, and only when nothing else shares the console.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
buffer = (ctypes.c_uint * 4)()
|
||||
count = win32dll.kernel32().GetConsoleProcessList(buffer, 4)
|
||||
except Exception: # pragma: no cover - no console at all, or a stubbed kernel32
|
||||
return False
|
||||
return count == 1
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Console-script entry point."""
|
||||
app()
|
||||
"""Console-script entry point.
|
||||
|
||||
Holds the window open after a failure when MeshTerm owns it, because otherwise the
|
||||
error is drawn and destroyed together and the person who needs to read it sees a
|
||||
flash. Only on that path: a successful run should not make anyone press a key, and a
|
||||
run from a shell leaves its output on the screen anyway.
|
||||
"""
|
||||
try:
|
||||
app()
|
||||
except SystemExit as exit_request:
|
||||
if exit_request.code and _owns_its_console():
|
||||
_wait_before_the_window_closes()
|
||||
raise
|
||||
except BaseException:
|
||||
if _owns_its_console():
|
||||
_wait_before_the_window_closes()
|
||||
raise
|
||||
|
||||
|
||||
def _wait_before_the_window_closes() -> None:
|
||||
"""Ask for a keypress so a message stays readable, and never fail doing it."""
|
||||
try:
|
||||
print()
|
||||
print("-- Press Enter to close this window --")
|
||||
input()
|
||||
except Exception: # pragma: no cover - stdin closed, redirected, or gone
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -14,11 +14,10 @@ a multi-user secret store; treat the file accordingly.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import Contact, LoginResult, utcnow
|
||||
|
||||
|
||||
@@ -139,11 +138,4 @@ class AdminStore:
|
||||
|
||||
def _write(self, records: dict[str, dict]) -> None:
|
||||
"""Persist ``records`` atomically with owner-only permissions where supported."""
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(records, indent=2), encoding="utf-8")
|
||||
try: # best-effort: not all platforms/filesystems honor chmod
|
||||
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError: # pragma: no cover - platform-dependent
|
||||
pass
|
||||
tmp.replace(self._path)
|
||||
write_atomically(self._path, json.dumps(records, indent=2), owner_only=True)
|
||||
@@ -20,6 +20,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import utcnow
|
||||
|
||||
#: The direct (zero-hop) cadence choices offered in the editor, in hours.
|
||||
@@ -190,10 +191,7 @@ class AdvertStore:
|
||||
|
||||
def _write(self, records: dict[str, dict]) -> None:
|
||||
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(records, indent=2), encoding="utf-8")
|
||||
tmp.replace(self._path)
|
||||
write_atomically(self._path, json.dumps(records, indent=2))
|
||||
|
||||
|
||||
def _as_hours(value: object, default: int) -> int:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""THE way MeshTerm replaces a file it owns: all of the new contents, or none of them.
|
||||
|
||||
Every store under ``core/`` keeps its state in one file that it rewrites whole. Writing
|
||||
in place means a crash — or a laptop lid closing — halfway through leaves a truncated
|
||||
file where a contact list used to be, so each write goes to a neighbouring temporary file
|
||||
first and lands with a single rename, which the filesystem either does or doesn't.
|
||||
|
||||
The neighbouring file's name carries the writing process's id. That is not fussiness:
|
||||
MeshTerm's data directory is shared by every copy on the machine — a checkout, a
|
||||
downloaded build, a second instance for a second radio — and a fixed ``.tmp`` name means
|
||||
two of them writing at the same moment take turns clobbering one scratch file, then each
|
||||
rename whatever is in it over the real one. The rename is atomic; the thing being renamed
|
||||
was not. Per-process names make the two writes independent again, and the last rename
|
||||
simply wins, which is a lost update rather than a corrupted file.
|
||||
|
||||
``MESHTERM_HOME`` is the way to avoid sharing the directory in the first place — see
|
||||
:func:`meshterm.core.config.default_config_dir`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["write_atomically"]
|
||||
|
||||
|
||||
def write_atomically(path: Path, text: str, *, owner_only: bool = False) -> None:
|
||||
"""Replace ``path`` with ``text`` in one step, creating parent directories as needed.
|
||||
|
||||
Args:
|
||||
path: The file to replace. Its directory is created if it does not exist.
|
||||
text: The complete new contents, written as UTF-8.
|
||||
owner_only: Restrict the file to the owner (``0600``) before it is put in place,
|
||||
for anything holding a credential. Applied to the temporary file rather than
|
||||
the destination, so there is no moment where the real file exists with wider
|
||||
permissions. Best-effort: not every platform or filesystem honours it.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(f"{path.suffix}.{os.getpid()}.tmp")
|
||||
try:
|
||||
tmp.write_text(text, encoding="utf-8")
|
||||
if owner_only:
|
||||
try:
|
||||
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError: # pragma: no cover - platform-dependent
|
||||
pass
|
||||
tmp.replace(path)
|
||||
except BaseException:
|
||||
# A failed write leaves no litter behind. `missing_ok` because the failure may
|
||||
# well have been the write that would have created it.
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
@@ -30,6 +30,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .channels import (
|
||||
CHANNEL_SECRET_BYTES,
|
||||
CHANNEL_SLOT_EMPTY_RUN,
|
||||
@@ -156,10 +157,7 @@ class ChannelStore:
|
||||
if channels
|
||||
}
|
||||
}
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(self._path)
|
||||
write_atomically(self._path, json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def _channel_from_json(entry: object) -> RememberedChannel | None:
|
||||
|
||||
+19
-2
@@ -15,6 +15,7 @@ keys used to sit in this file with no screen behind them.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -26,15 +27,31 @@ else: # pragma: no cover - exercised only on 3.10
|
||||
import tomli as tomllib
|
||||
|
||||
|
||||
#: Points MeshTerm's config and data somewhere other than the home directory. For trying a
|
||||
#: build without letting it near a real history, for a portable install on a stick, and for
|
||||
#: running two radios out of two directories. Read every time rather than cached, so a test
|
||||
#: can move it between cases.
|
||||
CONFIG_DIR_ENV = "MESHTERM_HOME"
|
||||
|
||||
|
||||
def default_config_dir() -> Path:
|
||||
"""Return the directory MeshTerm uses for config and data.
|
||||
|
||||
Resolves to ``.meshterm`` under the OS-defined home directory (``%USERPROFILE%``
|
||||
on Windows, ``$HOME`` on Unix), as reported by :meth:`Path.home`.
|
||||
``$MESHTERM_HOME`` wins if it is set and not empty. Otherwise this resolves to
|
||||
``.meshterm`` under the OS-defined home directory (``%USERPROFILE%`` on Windows,
|
||||
``$HOME`` on Unix), as reported by :meth:`Path.home`.
|
||||
|
||||
The override exists because the database is the valuable part of an install — a
|
||||
running record of everything the radio has overheard — and there was no way to point a
|
||||
second copy of MeshTerm at a different one. A downloaded build would happily open the
|
||||
same 34MB file as the checkout it was built from.
|
||||
|
||||
Returns:
|
||||
The resolved configuration directory path (not guaranteed to exist).
|
||||
"""
|
||||
override = os.environ.get(CONFIG_DIR_ENV, "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return Path.home() / ".meshterm"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
r"""Installing and selecting a console font on Windows, for one user, without admin.
|
||||
|
||||
The classic Windows console does no font fallback: a character its configured font lacks
|
||||
is drawn as a box, and nothing rescues it (see :func:`meshterm.ui.termfont.emoji_support`
|
||||
for the sibling problem). Its default font is Consolas, which carries 57 of the 122
|
||||
non-ASCII characters MeshTerm draws and *none* of the 44 braille cells the charts are
|
||||
made of. Windows PowerShell's classic console is worse still — its default, Lucida
|
||||
Console, drops even ``●``.
|
||||
|
||||
So on that host MeshTerm offers to fix the font. Two Win32 facts make that possible
|
||||
without an installer and without administrator rights:
|
||||
|
||||
* A font installs **for one user** by copying it under ``%LOCALAPPDATA%\Microsoft\
|
||||
Windows\Fonts``, registering it under ``HKCU``, and telling the session about it with
|
||||
``AddFontResourceW`` plus a ``WM_FONTCHANGE`` broadcast. No elevation anywhere.
|
||||
* A console selects a font with ``SetCurrentConsoleFontEx``, which — verified on
|
||||
Windows 10 22H2 — accepts a face the console's own properties dialog will not even
|
||||
list. That dialog only offers what is registered under the machine-wide
|
||||
``Console\TrueTypeFont`` key (Lucida Console and Consolas here), so without this API
|
||||
the user could install the font and still be unable to pick it.
|
||||
|
||||
**A font install is one-way inside a session.** Once loaded, Windows locks the file: it
|
||||
cannot be deleted even by an elevated process, and even after the font cache service is
|
||||
restarted — it frees at the next logon. So nothing here offers to uninstall one, and the
|
||||
install is written to be safe to repeat rather than reversible.
|
||||
|
||||
Everything is best-effort. A failure at any step leaves the app running with the font it
|
||||
already had, which is the state it would have been in anyway.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import win32dll
|
||||
|
||||
#: Where the bundled font lives, found the way every other asset is (see
|
||||
#: :mod:`meshterm.ui.about`) so it keeps resolving inside a PyInstaller bundle.
|
||||
FONT_DIR = Path(__file__).resolve().parent.parent / "assets" / "fonts"
|
||||
|
||||
#: The font MeshTerm ships and offers to install. Cascadia Mono PL is Microsoft's own
|
||||
#: console font, SIL OFL (licence beside it), and the ``PL`` build adds the powerline
|
||||
#: separators the path lines are drawn with — 723KB for 109 of our 122 characters, all 44
|
||||
#: braille cells and all 3 powerline glyphs, where the 2.4MB Nerd Font build covers no
|
||||
#: more. Practically every mainstream coder font — Hack, JetBrains Mono, Fira Code, Source
|
||||
#: Code Pro, even DejaVu Sans *Mono* — carries no braille at all; Cascadia and Iosevka are
|
||||
#: the exceptions, which is why this is not a matter of taste.
|
||||
BUNDLED_FONT = FONT_DIR / "CascadiaMonoPL.ttf"
|
||||
|
||||
#: Its family name, as the ``name`` table spells it — what ``SetCurrentConsoleFontEx``
|
||||
#: and the ``HKCU`` registration must both be given, exactly.
|
||||
BUNDLED_FACE = "Cascadia Mono PL"
|
||||
|
||||
_USER_FONTS = "Microsoft/Windows/Fonts"
|
||||
_FONT_REGISTRY = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts"
|
||||
|
||||
_HWND_BROADCAST = 0xFFFF
|
||||
_WM_FONTCHANGE = 0x001D
|
||||
_SMTO_ABORTIFHUNG = 0x0002
|
||||
|
||||
#: ``FF_MODERN | TMPF_VECTOR | TMPF_TRUETYPE`` — what a console expects a TrueType face to
|
||||
#: declare. Passing 0 here makes the call succeed and quietly keep the old raster font.
|
||||
_FF_MODERN_TRUETYPE = 54
|
||||
|
||||
|
||||
def user_font_dir() -> Path:
|
||||
r"""The per-user font folder — writable without admin, unlike ``C:\Windows\Fonts``."""
|
||||
local = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
||||
return Path(local) / _USER_FONTS
|
||||
|
||||
|
||||
def install_bundled_font() -> bool:
|
||||
"""Install the bundled font for this user, and make the session aware of it.
|
||||
|
||||
Safe to repeat: an already-installed copy is left alone rather than overwritten, both
|
||||
because the file is locked once loaded and because there is nothing to gain.
|
||||
|
||||
Returns:
|
||||
``True`` when the font is installed and usable (including when it already was).
|
||||
"""
|
||||
if sys.platform != "win32" or not BUNDLED_FONT.is_file():
|
||||
return False
|
||||
try:
|
||||
import ctypes
|
||||
import winreg
|
||||
|
||||
target = user_font_dir() / BUNDLED_FONT.name
|
||||
if not target.exists():
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(BUNDLED_FONT, target)
|
||||
|
||||
if not _add_font_resource(target):
|
||||
return False
|
||||
|
||||
# The registration is what survives a logout; AddFontResourceW alone lasts only
|
||||
# as long as this session. Per-user entries hold the full path, machine-wide ones
|
||||
# hold a bare filename — this is the per-user key, so the path goes in.
|
||||
with winreg.CreateKeyEx(
|
||||
winreg.HKEY_CURRENT_USER, _FONT_REGISTRY, 0, winreg.KEY_SET_VALUE
|
||||
) as key:
|
||||
winreg.SetValueEx(key, f"{BUNDLED_FACE} (TrueType)", 0, winreg.REG_SZ, str(target))
|
||||
|
||||
# Tell everything already running that the font list changed. Timed out and
|
||||
# allowed to abort on a hung window, because a broadcast that waits on every top
|
||||
# level window is a good way to stall a startup path.
|
||||
user32 = win32dll.user32()
|
||||
user32.SendMessageTimeoutW(
|
||||
_HWND_BROADCAST,
|
||||
_WM_FONTCHANGE,
|
||||
0,
|
||||
0,
|
||||
_SMTO_ABORTIFHUNG,
|
||||
1000,
|
||||
ctypes.byref(ctypes.c_ulong()),
|
||||
)
|
||||
return True
|
||||
except Exception: # pragma: no cover - an install that fails leaves the old font
|
||||
return False
|
||||
|
||||
|
||||
def _add_font_resource(path: Path) -> bool:
|
||||
"""Make a font file usable by this process, without installing anything.
|
||||
|
||||
``AddFontResourceW`` adds to the font table for the running process; the ``HKCU``
|
||||
registration beside it is what makes the font permanent, and Windows reads that at the
|
||||
*next logon*. Between those two facts sits the case this exists for: a second MeshTerm
|
||||
launch in the same session finds its own registration and believes the font is ready,
|
||||
when nothing has loaded it into this new process yet.
|
||||
|
||||
Args:
|
||||
path: The font file, wherever it is.
|
||||
|
||||
Returns:
|
||||
Whether at least one face was added.
|
||||
"""
|
||||
if sys.platform != "win32" or not path.is_file():
|
||||
return False
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
|
||||
return bool(gdi32.AddFontResourceW(ctypes.c_wchar_p(str(path))))
|
||||
except Exception: # pragma: no cover - best effort, like everything else here
|
||||
return False
|
||||
|
||||
|
||||
def use(face: str) -> bool:
|
||||
"""Draw this console with ``face``, loading our own copy first if that is what it is.
|
||||
|
||||
The plain :func:`select` is enough for a font the system installed — the Cascadia that
|
||||
comes with Windows 11 or with Windows Terminal. It is *not* enough for the copy
|
||||
MeshTerm installed itself in an earlier run of the same session: that one is registered
|
||||
but not yet loaded (see :func:`_add_font_resource`), and selecting it silently keeps
|
||||
the old font. So a refusal is retried once, after loading it.
|
||||
|
||||
Args:
|
||||
face: The family to draw with.
|
||||
|
||||
Returns:
|
||||
Whether the console is now drawing with ``face``.
|
||||
"""
|
||||
if select(face):
|
||||
return True
|
||||
if face == BUNDLED_FACE and _add_font_resource(user_font_dir() / BUNDLED_FONT.name):
|
||||
return select(face)
|
||||
return False
|
||||
|
||||
|
||||
def current_face() -> str | None:
|
||||
"""The face this console is drawing with, or ``None`` off a console."""
|
||||
return _console_font(None)
|
||||
|
||||
|
||||
def select(face: str) -> bool:
|
||||
"""Point this console at ``face``, and confirm it took.
|
||||
|
||||
The call reports success even where the console quietly kept the font it had, so the
|
||||
answer here is a read-back rather than the return value.
|
||||
|
||||
Args:
|
||||
face: The family name, spelled as the font's own ``name`` table spells it.
|
||||
|
||||
Returns:
|
||||
Whether the console is now drawing with ``face``.
|
||||
"""
|
||||
return _console_font(face) == face
|
||||
|
||||
|
||||
def _console_font(face: str | None) -> str | None:
|
||||
"""Read the console's font, first setting it to ``face`` when one is given.
|
||||
|
||||
One function for both because the struct, the handle and the failure modes are the
|
||||
same, and because setting without reading back proves nothing.
|
||||
|
||||
Args:
|
||||
face: The family to select, or ``None`` to only read.
|
||||
|
||||
Returns:
|
||||
The face the console is drawing with afterwards, or ``None`` if it could not
|
||||
be asked (no console, not Windows, a failed call).
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class _COORD(ctypes.Structure):
|
||||
_fields_ = [("X", wintypes.SHORT), ("Y", wintypes.SHORT)]
|
||||
|
||||
class _CONSOLE_FONT_INFOEX(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbSize", wintypes.ULONG),
|
||||
("nFont", wintypes.DWORD),
|
||||
("dwFontSize", _COORD),
|
||||
("FontFamily", wintypes.UINT),
|
||||
("FontWeight", wintypes.UINT),
|
||||
("FaceName", ctypes.c_wchar * 32),
|
||||
]
|
||||
|
||||
kernel32 = win32dll.kernel32()
|
||||
kernel32.GetStdHandle.argtypes = [wintypes.DWORD]
|
||||
kernel32.GetStdHandle.restype = wintypes.HANDLE
|
||||
for name in ("GetCurrentConsoleFontEx", "SetCurrentConsoleFontEx"):
|
||||
fn = getattr(kernel32, name)
|
||||
fn.argtypes = [wintypes.HANDLE, wintypes.BOOL, ctypes.POINTER(_CONSOLE_FONT_INFOEX)]
|
||||
fn.restype = wintypes.BOOL
|
||||
|
||||
handle = kernel32.GetStdHandle(wintypes.DWORD(-11).value) # STD_OUTPUT_HANDLE
|
||||
info = _CONSOLE_FONT_INFOEX()
|
||||
info.cbSize = ctypes.sizeof(_CONSOLE_FONT_INFOEX)
|
||||
if not kernel32.GetCurrentConsoleFontEx(handle, False, ctypes.byref(info)):
|
||||
return None
|
||||
|
||||
if face is not None:
|
||||
# Keep the height the reader already chose and let the width follow the face;
|
||||
# a console font is picked for legibility at a size, and changing that out
|
||||
# from under someone is not what they agreed to.
|
||||
info.FaceName = face[:31]
|
||||
info.FontFamily = _FF_MODERN_TRUETYPE
|
||||
info.dwFontSize = _COORD(0, info.dwFontSize.Y or 16)
|
||||
info.FontWeight = 400
|
||||
kernel32.SetCurrentConsoleFontEx(handle, False, ctypes.byref(info))
|
||||
after = _CONSOLE_FONT_INFOEX()
|
||||
after.cbSize = ctypes.sizeof(_CONSOLE_FONT_INFOEX)
|
||||
if not kernel32.GetCurrentConsoleFontEx(handle, False, ctypes.byref(after)):
|
||||
return None
|
||||
return after.FaceName or None
|
||||
return info.FaceName or None
|
||||
except Exception: # pragma: no cover - a probe that fails is an unknown, not a crash
|
||||
return None
|
||||
@@ -49,6 +49,7 @@ import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import Contact, advert_time
|
||||
|
||||
|
||||
@@ -332,10 +333,7 @@ class ContactStore:
|
||||
if contacts
|
||||
}
|
||||
}
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(self._path)
|
||||
write_atomically(self._path, json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def _contact_to_json(contact: RememberedContact) -> dict:
|
||||
|
||||
@@ -19,6 +19,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import utcnow
|
||||
|
||||
#: Message states: waiting in the outbox, landed, or abandoned after the retry budget.
|
||||
@@ -253,10 +254,7 @@ class CourierStore:
|
||||
for m in self._load()
|
||||
],
|
||||
}
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(self._path)
|
||||
write_atomically(self._path, json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def _as_int(value: object, default: int) -> int:
|
||||
|
||||
@@ -19,6 +19,7 @@ import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .discovery import (
|
||||
TRANSPORT_BLE,
|
||||
TRANSPORT_SERIAL,
|
||||
@@ -259,7 +260,4 @@ class DeviceStore:
|
||||
},
|
||||
"last": last,
|
||||
}
|
||||
# Atomic-ish replace so a crash mid-write can't truncate the existing registry.
|
||||
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
tmp.replace(self._path)
|
||||
write_atomically(self._path, json.dumps(payload, indent=2))
|
||||
@@ -0,0 +1,94 @@
|
||||
"""One interactive MeshTerm per data directory, and a clear answer when you want two.
|
||||
|
||||
Every copy of MeshTerm on a machine reads and writes the same ``~/.meshterm`` — a
|
||||
checkout, a downloaded build, a second instance for a second radio. Nothing about how it
|
||||
was installed changes that. Two running at once will not corrupt anything (the database
|
||||
is in WAL mode and every file lands by rename) but each holds the JSON stores in memory
|
||||
and rewrites them whole, so the second one to save quietly discards the first one's
|
||||
changes. A lost contact is much harder to notice than a crash.
|
||||
|
||||
So the interactive session takes an exclusive lock on the directory it is using, and a
|
||||
second one is turned away with the fix in the message: set ``MESHTERM_HOME``.
|
||||
|
||||
The lock is held by the operating system rather than written down as a process id in a
|
||||
file. A pid file has to answer "is that process still alive?", the answer is different on
|
||||
every platform, and it is wrong after a reboot recycles the number — so a crash leaves a
|
||||
lock nobody can explain and everybody works around. An OS lock is released when the
|
||||
process ends, however it ends.
|
||||
|
||||
One-shot CLI subcommands do not take it. They are brief, mostly read, and blocking
|
||||
``meshterm contacts`` because a menu is open elsewhere would be an obstacle rather than a
|
||||
guard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["InstanceBusy", "LOCK_NAME", "hold_instance_lock"]
|
||||
|
||||
#: The lock file, kept beside the data it guards. Empty; only the lock on it matters.
|
||||
LOCK_NAME = ".lock"
|
||||
|
||||
|
||||
class InstanceBusy(RuntimeError):
|
||||
"""Raised when another interactive MeshTerm already holds this data directory."""
|
||||
|
||||
def __init__(self, config_dir: Path) -> None:
|
||||
"""Build the message, which is mostly the way out of the situation."""
|
||||
super().__init__(
|
||||
f"Another MeshTerm is already using {config_dir}.\n\n"
|
||||
"Two of them sharing one directory will quietly overwrite each other's\n"
|
||||
"contacts and settings, so this one stopped instead.\n\n"
|
||||
"To run a second one — a downloaded build beside your own, or a second\n"
|
||||
"radio — give it a directory of its own:\n\n"
|
||||
" MESHTERM_HOME=~/meshterm-other meshterm\n"
|
||||
)
|
||||
self.config_dir = config_dir
|
||||
|
||||
|
||||
def _try_lock(handle) -> bool:
|
||||
"""Take an exclusive, non-blocking lock on an open file; ``False`` if someone has it."""
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
try:
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
import fcntl
|
||||
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@contextmanager
|
||||
def hold_instance_lock(config_dir: Path) -> Iterator[None]:
|
||||
"""Hold ``config_dir`` for this process, releasing it however the process ends.
|
||||
|
||||
Args:
|
||||
config_dir: The data directory to claim.
|
||||
|
||||
Raises:
|
||||
InstanceBusy: Another interactive MeshTerm holds it.
|
||||
"""
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = config_dir / LOCK_NAME
|
||||
handle = open(path, "a+b") # noqa: SIM115 - closed in the finally below
|
||||
try:
|
||||
if not _try_lock(handle):
|
||||
raise InstanceBusy(config_dir)
|
||||
yield
|
||||
finally:
|
||||
# Closing releases the lock on every platform. The file itself stays: deleting it
|
||||
# would race a second process that has already opened it and is about to lock,
|
||||
# which is how one directory ends up handed to two of them.
|
||||
handle.close()
|
||||
@@ -23,6 +23,8 @@ from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
|
||||
|
||||
class MuteStore:
|
||||
"""Reads and writes the set of channels whose notifications are muted, memory-first.
|
||||
@@ -92,7 +94,4 @@ class MuteStore:
|
||||
def _save(self) -> None:
|
||||
"""Persist the whole muted set atomically (a crash mid-write keeps the old file)."""
|
||||
data = {"muted": sorted(self._state)}
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(self._path)
|
||||
write_atomically(self._path, json.dumps(data, indent=2))
|
||||
@@ -42,6 +42,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .watch_store import DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
|
||||
|
||||
#: Display groups, in the order the page and the file present them. The order is the
|
||||
@@ -56,6 +57,7 @@ GROUPS: tuple[str, ...] = (
|
||||
"Map",
|
||||
"History",
|
||||
"Display",
|
||||
"Diagnostics",
|
||||
)
|
||||
|
||||
|
||||
@@ -123,6 +125,24 @@ _WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
|
||||
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
|
||||
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
|
||||
#: default — nothing else has to follow.
|
||||
#: What the log file keeps. The plain level names, which are what every other tool calls
|
||||
#: these — someone being talked through a problem is being told "set it to debug", not
|
||||
#: "set it to everything".
|
||||
#: What MeshTerm may offer when it lands in a console that cannot draw it. Only ever
|
||||
#: consulted on the classic Windows console, the one host with no font fallback of its own.
|
||||
_CONSOLE_SETUP_CHOICES: dict[str, str] = {
|
||||
"auto": "Automatic",
|
||||
"off": "Leave it alone",
|
||||
}
|
||||
|
||||
_LOG_LEVEL_CHOICES: dict[str, str] = {
|
||||
"ERROR": "Error",
|
||||
"WARNING": "Warning",
|
||||
"INFO": "Info",
|
||||
"DEBUG": "Debug",
|
||||
}
|
||||
|
||||
|
||||
PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
# --- Sending -----------------------------------------------------------------
|
||||
PrefSpec(
|
||||
@@ -282,6 +302,34 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
default="auto",
|
||||
choices=_WIDTH_CHOICES,
|
||||
),
|
||||
PrefSpec(
|
||||
key="console_setup",
|
||||
label="Console setup",
|
||||
help="Move to a better terminal when this console can't draw MeshTerm",
|
||||
group="Display",
|
||||
value_type="enum",
|
||||
# Reopening in Windows Terminal is done, not asked: the classic console cannot
|
||||
# draw a single icon whatever font it is given, so there is only one sensible
|
||||
# answer and the reader has not seen the app yet to judge it. Installing a *font*
|
||||
# still asks, because that writes to their machine. Declining that twice writes
|
||||
# "off" here, which turns both off for good.
|
||||
default="auto",
|
||||
choices=_CONSOLE_SETUP_CHOICES,
|
||||
),
|
||||
# --- Diagnostics ---------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="log_level",
|
||||
label="Log detail",
|
||||
help="How much MeshTerm writes to its log file",
|
||||
group="Diagnostics",
|
||||
value_type="enum",
|
||||
# Problems, not a narration of a working session. The file used to take
|
||||
# everything, which buried the dozen interesting lines under twenty thousand
|
||||
# dull ones and made it awkward to attach to a bug report.
|
||||
default="WARNING",
|
||||
choices=_LOG_LEVEL_CHOICES,
|
||||
relaunch=True,
|
||||
),
|
||||
)
|
||||
|
||||
_BY_KEY: dict[str, PrefSpec] = {spec.key: spec for spec in PREFERENCES}
|
||||
@@ -602,10 +650,7 @@ class Preferences:
|
||||
"""
|
||||
if self._path is None:
|
||||
raise RuntimeError("these preferences have no file to save to")
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
tmp.write_text(self.as_yaml(), encoding="utf-8")
|
||||
tmp.replace(self._path)
|
||||
write_atomically(self._path, self.as_yaml())
|
||||
|
||||
|
||||
#: The set installed for this process. Starts as a defaults-only, file-less instance so
|
||||
|
||||
@@ -17,6 +17,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .admin_store import admin_key
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import Contact, utcnow
|
||||
|
||||
#: How many command-line entries are kept per node (newest last).
|
||||
@@ -109,7 +110,4 @@ class RemoteStore:
|
||||
|
||||
def _write(self, records: dict[str, dict]) -> None:
|
||||
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(records, indent=2), encoding="utf-8")
|
||||
tmp.replace(self._path)
|
||||
write_atomically(self._path, json.dumps(records, indent=2))
|
||||
@@ -32,6 +32,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .device_config import DeviceConfigError, format_value, get_spec, parse_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -147,10 +148,7 @@ class SettingsStore:
|
||||
if values
|
||||
}
|
||||
}
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(self._path)
|
||||
write_atomically(self._path, json.dumps(data, indent=2))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Private handles on the Windows DLLs, because ``ctypes.windll`` is shared with everyone.
|
||||
|
||||
``ctypes.windll.kernel32`` is a cache. Every caller in the process gets the *same*
|
||||
``WinDLL`` object, and each function on it is a cached attribute — so
|
||||
``ctypes.windll.kernel32.GetConsoleScreenBufferInfo`` is one object, shared by this
|
||||
application and every library inside it. Setting ``.argtypes`` on it is not configuration,
|
||||
it is a process-wide mutation, and the last writer wins.
|
||||
|
||||
That is not theoretical. It crashed MeshTerm on Windows:
|
||||
|
||||
* ``ui/tui/emoji_width.py`` measured an emoji by asking the console for its cursor
|
||||
position, and declared ``GetConsoleScreenBufferInfo`` as taking a pointer to *its* copy
|
||||
of the screen-buffer struct.
|
||||
* prompt_toolkit later called the very same function object with a pointer to *its* copy
|
||||
of the same struct — identical layout, different class.
|
||||
* ctypes compared the two classes, found them unequal, and refused:
|
||||
``expected LP__CSBI instance instead of pointer to CONSOLE_SCREEN_BUFFER_INFO``.
|
||||
|
||||
The application died while building its screen, which on a double-clicked build meant the
|
||||
traceback and the window it was drawn in disappeared together.
|
||||
|
||||
Constructing ``WinDLL`` directly builds a *new* object with its own function cache, so
|
||||
declarations made on it are visible only to the caller that made them. Each call here
|
||||
returns a fresh one: two parts of MeshTerm declaring the same function differently is the
|
||||
same bug in miniature, and the cost is one small object at a call site that runs rarely.
|
||||
|
||||
The rule this exists to enforce: **nothing in MeshTerm touches ``ctypes.windll``.**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import sys
|
||||
|
||||
__all__ = ["kernel32", "user32"]
|
||||
|
||||
|
||||
def _library(name: str) -> ctypes.WinDLL:
|
||||
"""Load ``name`` into a WinDLL of our own.
|
||||
|
||||
Args:
|
||||
name: The DLL to load, without its extension.
|
||||
|
||||
Returns:
|
||||
A private handle whose function signatures nobody else can see or change.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called anywhere but Windows, where these do not exist.
|
||||
"""
|
||||
if sys.platform != "win32": # pragma: no cover - the callers all guard on platform
|
||||
raise RuntimeError(f"{name} is a Windows library")
|
||||
# `use_last_error` keeps GetLastError intact for the caller, which is the only way to
|
||||
# tell "the call failed" from "the call succeeded and returned zero".
|
||||
return ctypes.WinDLL(name, use_last_error=True)
|
||||
|
||||
|
||||
def kernel32() -> ctypes.WinDLL:
|
||||
"""A private handle on ``kernel32`` — console, handles, memory."""
|
||||
return _library("kernel32")
|
||||
|
||||
|
||||
def user32() -> ctypes.WinDLL:
|
||||
"""A private handle on ``user32`` — clipboard, keyboard state, windows."""
|
||||
return _library("user32")
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Reopening the session in Windows Terminal, which is where MeshTerm looks like itself.
|
||||
|
||||
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether any
|
||||
of that reaches the screen is decided by the *terminal*, not by MeshTerm and not really by
|
||||
the font either: a modern terminal, asked for a character its font lacks, quietly borrows
|
||||
the glyph from another font on the machine. That is why the app looks right in Windows
|
||||
Terminal, in VS Code's terminal, and on macOS and Linux — and why it looks right there
|
||||
even though the font the reader chose usually holds almost none of it. Measured on a
|
||||
development machine, the everyday choices — Hack Nerd Font, JetBrains Mono, Fira Code,
|
||||
Source Code Pro — carry no braille at all, and no monospace font anywhere carries emoji.
|
||||
|
||||
The classic Windows console does not do that. It draws what its one font holds and boxes
|
||||
for the rest, so on that host the emoji cannot be shown *at all* — the only two fonts on a
|
||||
Windows machine with emoji glyphs, Segoe UI Emoji and Segoe UI Symbol, are proportional,
|
||||
and a console will not take a proportional font. No font choice fixes it, which leaves
|
||||
exactly one thing that does: a different terminal.
|
||||
|
||||
So when MeshTerm finds itself in that console and Windows Terminal is installed — it is on
|
||||
every Windows 11 machine, and a free install on 10 — it offers to reopen itself there.
|
||||
One keypress, and the app looks the way it was drawn instead of the way conhost can
|
||||
manage. Declining falls back to :mod:`meshterm.core.consolefont`, which makes the best of
|
||||
the console the reader chose to stay in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
#: Set in the reopened session, so a relaunch can never chain into another one. Windows
|
||||
#: Terminal is not a classic console and so would not qualify anyway — this is the belt to
|
||||
#: that braces, and it costs one environment variable.
|
||||
REOPENED_ENV = "MESHTERM_REOPENED"
|
||||
|
||||
|
||||
def available() -> str | None:
|
||||
"""The path to ``wt.exe``, or ``None`` when Windows Terminal is not installed.
|
||||
|
||||
Found on ``PATH``, where Windows puts an execution alias for it under
|
||||
``%LOCALAPPDATA%/Microsoft/WindowsApps``.
|
||||
"""
|
||||
if sys.platform != "win32" or os.environ.get(REOPENED_ENV):
|
||||
return None
|
||||
return shutil.which("wt.exe")
|
||||
|
||||
|
||||
def own_command() -> list[str]:
|
||||
"""The command that would start this same session again.
|
||||
|
||||
A frozen build is its own executable and takes the arguments as they were given. A
|
||||
development or ``pip`` install is reached through its interpreter, because the console
|
||||
script's own path is not something ``wt`` can be relied on to resolve.
|
||||
|
||||
Returns:
|
||||
The program and its arguments, ready to hand to Windows Terminal.
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return [sys.executable, *sys.argv[1:]]
|
||||
return [sys.executable, "-m", "meshterm", *sys.argv[1:]]
|
||||
|
||||
|
||||
def child_environment() -> dict[str, str]:
|
||||
"""The environment for a fresh launch of MeshTerm, minus this bundle's bookkeeping.
|
||||
|
||||
A PyInstaller one-file build runs in two stages: the executable unpacks itself into a
|
||||
temporary directory and then runs itself again as a child, the two halves coordinating
|
||||
through ``_PYI*`` environment variables. Those must not reach a *new* launch of the
|
||||
executable. The bootloader would find them, conclude it is the second stage of a launch
|
||||
it never made, check that its parent is the same program — find Windows Terminal
|
||||
instead — and abort::
|
||||
|
||||
[PYI-33368:ERROR] Security validation failure: parent process has different
|
||||
executable!
|
||||
|
||||
Which is the whole session gone, in a brand new window holding a message nobody can act
|
||||
on, while the window that offered the move has already closed. Removing them makes the
|
||||
new process a first stage, which is what it actually is.
|
||||
|
||||
Returns:
|
||||
The environment to start the new process with.
|
||||
"""
|
||||
environment = dict(os.environ)
|
||||
environment[REOPENED_ENV] = "1"
|
||||
if not getattr(sys, "frozen", False):
|
||||
return environment
|
||||
|
||||
for key in [key for key in environment if key.startswith("_PYI")]:
|
||||
del environment[key]
|
||||
environment.pop("_MEIPASS2", None) # the name older bootloaders used
|
||||
# The bootloader points the loader at the unpacked bundle and parks the caller's own
|
||||
# value beside it. The new process unpacks its own copy, so it wants the original.
|
||||
for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
|
||||
original = environment.pop(f"{name}_ORIG", None)
|
||||
if original is not None:
|
||||
environment[name] = original
|
||||
else:
|
||||
environment.pop(name, None)
|
||||
return environment
|
||||
|
||||
|
||||
def reopen() -> bool:
|
||||
"""Start this session again in Windows Terminal, in the same directory.
|
||||
|
||||
The new window is not a child in any meaningful sense — the caller exits immediately
|
||||
afterwards and the two never talk — so nothing is waited on and no pipes are held.
|
||||
|
||||
Returns:
|
||||
Whether Windows Terminal was started. ``False`` leaves the caller exactly where it
|
||||
was, which is why the offer is only ever a detour and never a dead end.
|
||||
"""
|
||||
terminal = available()
|
||||
if terminal is None:
|
||||
return False
|
||||
try:
|
||||
# `--` ends wt's own option parsing, so MeshTerm's flags reach MeshTerm rather
|
||||
# than being read as Windows Terminal's (verified: `--mock` and `--profile x`
|
||||
# arrive intact). `-d` keeps the working directory, which relative paths given
|
||||
# on the command line depend on.
|
||||
subprocess.Popen( # noqa: S603 - the argv is ours, not the reader's
|
||||
[terminal, "-d", os.getcwd(), "--", *own_command()],
|
||||
env=child_environment(),
|
||||
close_fds=True,
|
||||
)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
@@ -1,13 +1,28 @@
|
||||
"""Structured logging setup.
|
||||
"""Logging setup: a plain text file you can open, and a console that stays out of the way.
|
||||
|
||||
Two sinks are configured: a Rich-formatted console handler for human-readable output,
|
||||
and a JSON-lines file handler that captures every record for later replay/analysis.
|
||||
Two sinks. The console handler is Rich-formatted and shows what a person watching the
|
||||
terminal should see. The file handler writes ordinary log lines to
|
||||
``<config_dir>/meshterm.log`` — one record per line, timestamp, level, logger, message,
|
||||
with a traceback indented under the line that raised it.
|
||||
|
||||
Plain text on purpose. The file used to be JSON Lines, on the theory that something might
|
||||
replay it; nothing ever did, and the one job it actually has is being opened by a person
|
||||
who has hit a problem — often the person reporting it, who then pastes the last twenty
|
||||
lines into an issue. That reader has a text editor, not a JSON parser.
|
||||
|
||||
The file rotates. It used to be a single :class:`~logging.FileHandler`, which grows
|
||||
without limit: a real one reached 11MB over ten weeks, which is both a lot of disk on a
|
||||
handheld and an unreasonable thing to attach to a bug report.
|
||||
|
||||
How much is written is a preference — ``log_level``, defaulting to ``WARNING``, so the
|
||||
file holds the problems rather than a narration of a working session. Turn it down to
|
||||
``INFO`` or ``DEBUG`` when chasing something.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
@@ -15,28 +30,49 @@ from rich.logging import RichHandler
|
||||
|
||||
_LOGGER_NAME = "meshterm"
|
||||
|
||||
#: The log file, in the config directory beside the data it describes.
|
||||
LOG_FILENAME = "meshterm.log"
|
||||
|
||||
class JsonlFormatter(logging.Formatter):
|
||||
"""Format log records as one JSON object per line."""
|
||||
#: Keep this much before rolling over, and this many rolled files. Roughly a fortnight of
|
||||
#: a chatty session at DEBUG, and small enough that the whole set can be attached to an
|
||||
#: issue without apology.
|
||||
_MAX_BYTES = 2 * 1024 * 1024
|
||||
_BACKUP_COUNT = 3
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Serialize a log record to a compact JSON string.
|
||||
#: ``2026-09-07 14:23:11 WARNING meshterm.core.connection: link lost, retrying``
|
||||
#: Level is padded so the messages line up; the logger name says which part spoke.
|
||||
_LINE_FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
|
||||
_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
Args:
|
||||
record: The record to format.
|
||||
|
||||
Returns:
|
||||
A single-line JSON document.
|
||||
"""
|
||||
payload = {
|
||||
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": record.getMessage(),
|
||||
}
|
||||
if record.exc_info:
|
||||
payload["exc"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload)
|
||||
#: The levels the ``log_level`` preference offers, by their own names. Spelled out rather
|
||||
#: than taken from ``logging.getLevelName``, whose string-to-number direction is deprecated,
|
||||
#: or ``getLevelNamesMapping``, which arrived in 3.11 and this package supports 3.10.
|
||||
_LEVELS: dict[str, int] = {
|
||||
"ERROR": logging.ERROR,
|
||||
"WARNING": logging.WARNING,
|
||||
"INFO": logging.INFO,
|
||||
"DEBUG": logging.DEBUG,
|
||||
}
|
||||
|
||||
|
||||
def level_from_name(name: str, *, fallback: int = logging.WARNING) -> int:
|
||||
"""Turn a ``log_level`` preference value into a level number.
|
||||
|
||||
Args:
|
||||
name: A level name, in any case.
|
||||
fallback: Used when the name is unrecognised — a hand-edited ``preferences.yaml``
|
||||
with a typo in it should lose some log detail, not stop the app from starting.
|
||||
|
||||
Returns:
|
||||
The matching level number.
|
||||
"""
|
||||
return _LEVELS.get(name.strip().upper(), fallback)
|
||||
|
||||
|
||||
def log_path(log_dir: Path) -> Path:
|
||||
"""Return the log file's path, for an error message that wants to name it."""
|
||||
return log_dir / LOG_FILENAME
|
||||
|
||||
|
||||
def configure_logging(
|
||||
@@ -44,14 +80,16 @@ def configure_logging(
|
||||
log_dir: Path,
|
||||
*,
|
||||
level: int = logging.INFO,
|
||||
file_level: int = logging.WARNING,
|
||||
quiet: bool = False,
|
||||
) -> logging.Logger:
|
||||
"""Configure and return the application logger.
|
||||
|
||||
Args:
|
||||
console: The Rich console the handler should render to.
|
||||
log_dir: Directory for the ``meshterm.log.jsonl`` event file.
|
||||
log_dir: Directory for the ``meshterm.log`` file.
|
||||
level: Console log level.
|
||||
file_level: File log level, from the ``log_level`` preference.
|
||||
quiet: When ``True``, suppress console output (file logging continues).
|
||||
|
||||
Returns:
|
||||
@@ -70,9 +108,14 @@ def configure_logging(
|
||||
logger.addHandler(rich_handler)
|
||||
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(log_dir / "meshterm.log.jsonl", encoding="utf-8")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(JsonlFormatter())
|
||||
file_handler = RotatingFileHandler(
|
||||
log_path(log_dir),
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(file_level)
|
||||
file_handler.setFormatter(logging.Formatter(_LINE_FORMAT, datefmt=_TIME_FORMAT))
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
_quiet_library_console(file_handler)
|
||||
@@ -100,8 +143,8 @@ def _quiet_library_console(file_handler: logging.Handler) -> None:
|
||||
1. Give the root logger a :class:`~logging.NullHandler` so ``basicConfig`` — which only
|
||||
acts when the root has no handlers — becomes a no-op and never adds its stream handler.
|
||||
2. Take each known library logger off propagation and attach only ``file_handler``, so
|
||||
its records are still captured to the JSON-lines log but never reach the console, even
|
||||
if some other code path installs a root stream handler anyway.
|
||||
its records are still captured to the log but never reach the console, even if some
|
||||
other code path installs a root stream handler anyway.
|
||||
|
||||
Args:
|
||||
file_handler: The application's file handler to also capture library records to.
|
||||
|
||||
+21
-1
@@ -19,7 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -342,10 +342,30 @@ def resolve(flag: str | None = None) -> Resolution:
|
||||
return Resolution(flag, env, model, "default", REGULAR)
|
||||
|
||||
|
||||
def without_emoji(platform: Platform) -> Platform:
|
||||
"""The same platform with its icons routed through the compact glyph table.
|
||||
|
||||
A terminal that cannot draw emoji is not a different *flavour* — the classic Windows
|
||||
console has the desktop's width, colour depth and keyboard, and wants every other
|
||||
thing :data:`REGULAR` says. Only the one flag moves, which is what the seam's flags
|
||||
being independent is for: ``ascii_fold`` stays off, so accents and the rest of the
|
||||
BMP still come through.
|
||||
|
||||
Args:
|
||||
platform: The resolved platform.
|
||||
|
||||
Returns:
|
||||
``platform`` itself when its icons are already compact, else a copy with
|
||||
``emoji`` cleared.
|
||||
"""
|
||||
return platform if not platform.emoji else replace(platform, emoji=False)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Platform",
|
||||
"REGULAR",
|
||||
"PICOCALC",
|
||||
"without_emoji",
|
||||
"PLATFORM",
|
||||
"get_platform",
|
||||
"set_platform",
|
||||
|
||||
+261
-2
@@ -1,4 +1,25 @@
|
||||
"""Terminal font detection — can this terminal draw powerline path separators?
|
||||
"""Terminal capability detection — what can this terminal actually draw?
|
||||
|
||||
Two questions, both answered out-of-band because a terminal cannot be asked in-band: a
|
||||
glyph it does not have still occupies its cell, so the screen looks the same whether the
|
||||
character arrived or not.
|
||||
|
||||
**Powerline separators**, below, are a *font* question — read the terminal's configured
|
||||
face and match it against fonts known to carry the block.
|
||||
|
||||
**Emoji** (:func:`emoji_support`) are a *host* question, and only on Windows. The classic
|
||||
console — ``conhost``, which is what a double-clicked program still gets — rasterises
|
||||
through GDI with the console font and nothing behind it, so an emoji lands as a
|
||||
replacement box however the font is configured. Everything that replaced it draws them
|
||||
fine. So the verdict identifies the host, structurally rather than from the environment
|
||||
(``WT_SESSION`` is inherited across process launches, so a program started from Windows
|
||||
Terminal into a console of its own still claims to be in one), and a ``False`` routes
|
||||
every icon through the compact single-glyph table :func:`meshterm.ui.theme.glyph` already
|
||||
keeps for the PicoCalc — that whole vocabulary is BMP, so it draws wherever the plain
|
||||
status marks already do.
|
||||
|
||||
Both verdicts are hints that pick a default, and both have an override for the reader who
|
||||
knows better than the probe: ``MESHTERM_POWERLINE`` and ``MESHTERM_EMOJI``.
|
||||
|
||||
The path-line widget (:mod:`~meshterm.ui.pathline`) can render a hop sequence as
|
||||
interlocking powerline segments — each hop a colour-filled chip, joined by the solid
|
||||
@@ -57,6 +78,8 @@ from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from ..core import win32dll
|
||||
|
||||
#: Coverage levels a verdict (or a recommended font) can carry, strongest first.
|
||||
FULL = "full"
|
||||
CORE = "core"
|
||||
@@ -460,7 +483,7 @@ def _conhost_face() -> str | None:
|
||||
import ctypes
|
||||
import ctypes.wintypes as wintypes
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
kernel32 = win32dll.kernel32()
|
||||
if not kernel32.GetConsoleWindow():
|
||||
return None
|
||||
|
||||
@@ -675,3 +698,239 @@ def installed_recommended() -> RecommendedFont | None:
|
||||
return matched
|
||||
best = best or matched
|
||||
return best
|
||||
|
||||
|
||||
# --- emoji support ----------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmojiSupport:
|
||||
"""Whether this terminal can show an emoji at all, and what decided that.
|
||||
|
||||
Attributes:
|
||||
supported: ``True`` when an emoji icon reaches the screen as itself.
|
||||
source: ``"env"`` (the ``MESHTERM_EMOJI`` override), ``"platform"`` (not Windows,
|
||||
so not the classic console's problem), ``"console"`` (the host was identified
|
||||
— see :func:`_is_classic_console`), or ``"no-console"`` (nothing to identify:
|
||||
output is redirected, or the call failed).
|
||||
"""
|
||||
|
||||
supported: bool
|
||||
source: str
|
||||
|
||||
|
||||
def _is_classic_console() -> bool | None:
|
||||
"""Whether this process is drawing into a genuine ``conhost`` window.
|
||||
|
||||
Which is the whole question, because that host renders through GDI with the console
|
||||
font and no emoji font behind it, while everything that replaced it — Windows
|
||||
Terminal, VS Code's terminal, anything over ssh — draws emoji as a matter of course.
|
||||
|
||||
It cannot be asked in band. A missing glyph still occupies its cell, so the screen
|
||||
reads back exactly as it would have if the character had drawn: measured on Windows 10
|
||||
22H2, a classic console stores ``📡`` intact and advances two cells while showing a
|
||||
single replacement box, and a ConPTY (which does draw it) reads back as U+FFFD because
|
||||
the buffer behind it is updated asynchronously. A write-and-read-back probe answers
|
||||
both hosts *backwards*, which is the trap this docstring exists to record.
|
||||
|
||||
Nor can the environment be trusted alone: ``WT_SESSION`` is inherited by child
|
||||
processes, so a program launched from Windows Terminal into a console of its own still
|
||||
carries it and would answer for the wrong host.
|
||||
|
||||
So the host is identified structurally, from two independent facts, and both must
|
||||
agree before anything is dimmed (the costly mistake is stripping icons from a terminal
|
||||
that could draw them):
|
||||
|
||||
* **The console font has a real pixel width.** A ConPTY's hidden conhost reports a
|
||||
stub — face ``Consolas``, cell width ``0`` — because nothing is rasterised there.
|
||||
* **The buffer is taller than its window.** A classic console owns its scrollback
|
||||
(9001 rows by default); under a ConPTY the terminal owns it, so the buffer is
|
||||
exactly the window.
|
||||
|
||||
Returns:
|
||||
``True`` for a genuine classic console, ``False`` for anything else drawing to a
|
||||
console, and ``None`` when there is no console on stdout (redirected output, not
|
||||
Windows, or the call failed) — an unknown is never a reason to degrade.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class _COORD(ctypes.Structure):
|
||||
_fields_ = [("X", wintypes.SHORT), ("Y", wintypes.SHORT)]
|
||||
|
||||
class _SMALL_RECT(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Left", wintypes.SHORT),
|
||||
("Top", wintypes.SHORT),
|
||||
("Right", wintypes.SHORT),
|
||||
("Bottom", wintypes.SHORT),
|
||||
]
|
||||
|
||||
class _CSBI(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwSize", _COORD),
|
||||
("dwCursorPosition", _COORD),
|
||||
("wAttributes", wintypes.WORD),
|
||||
("srWindow", _SMALL_RECT),
|
||||
("dwMaximumWindowSize", _COORD),
|
||||
]
|
||||
|
||||
class _CONSOLE_FONT_INFOEX(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbSize", wintypes.ULONG),
|
||||
("nFont", wintypes.DWORD),
|
||||
("dwFontSize", _COORD),
|
||||
("FontFamily", wintypes.UINT),
|
||||
("FontWeight", wintypes.UINT),
|
||||
("FaceName", ctypes.c_wchar * 32),
|
||||
]
|
||||
|
||||
# Safe to declare: this handle is ours alone (see meshterm.core.win32dll). The
|
||||
# explicit restype matters — ctypes would assume a 32-bit int and truncate the
|
||||
# 64-bit HANDLE, turning every later call into a silent failure.
|
||||
kernel32 = win32dll.kernel32()
|
||||
kernel32.GetStdHandle.argtypes = [wintypes.DWORD]
|
||||
kernel32.GetStdHandle.restype = wintypes.HANDLE
|
||||
kernel32.GetConsoleScreenBufferInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(_CSBI)]
|
||||
kernel32.GetConsoleScreenBufferInfo.restype = wintypes.BOOL
|
||||
kernel32.GetCurrentConsoleFontEx.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
wintypes.BOOL,
|
||||
ctypes.POINTER(_CONSOLE_FONT_INFOEX),
|
||||
]
|
||||
kernel32.GetCurrentConsoleFontEx.restype = wintypes.BOOL
|
||||
|
||||
handle = kernel32.GetStdHandle(wintypes.DWORD(-11).value) # STD_OUTPUT_HANDLE
|
||||
info = _CSBI()
|
||||
# Fails when stdout is a pipe or a file — nothing is being drawn, so there is
|
||||
# nothing to answer, and this is what keeps redirected and CI runs out of it.
|
||||
if not kernel32.GetConsoleScreenBufferInfo(handle, ctypes.byref(info)):
|
||||
return None
|
||||
|
||||
font = _CONSOLE_FONT_INFOEX()
|
||||
font.cbSize = ctypes.sizeof(_CONSOLE_FONT_INFOEX)
|
||||
if not kernel32.GetCurrentConsoleFontEx(handle, False, ctypes.byref(font)):
|
||||
return None
|
||||
|
||||
rasterised = font.dwFontSize.X > 0
|
||||
owns_scrollback = info.dwSize.Y > (info.srWindow.Bottom - info.srWindow.Top + 1)
|
||||
return rasterised and owns_scrollback
|
||||
except Exception: # pragma: no cover - a probe that fails is an unknown, not a crash
|
||||
return None
|
||||
|
||||
|
||||
def _emoji_support(
|
||||
environ: Mapping[str, str] | None = None,
|
||||
*,
|
||||
console_probe: Callable[[], bool | None] = _is_classic_console,
|
||||
system: str | None = None,
|
||||
) -> EmojiSupport:
|
||||
"""The uncached verdict (see :func:`emoji_support` for the ladder)."""
|
||||
env = os.environ if environ is None else environ
|
||||
override = (env.get("MESHTERM_EMOJI") or "").strip().lower()
|
||||
if override in {"0", "off", "no", "none", "false"}:
|
||||
return EmojiSupport(False, "env")
|
||||
if override in {"1", "on", "yes", "true"}:
|
||||
return EmojiSupport(True, "env")
|
||||
|
||||
if (sys.platform if system is None else system) != "win32":
|
||||
return EmojiSupport(True, "platform")
|
||||
classic = console_probe()
|
||||
if classic is None:
|
||||
return EmojiSupport(True, "no-console")
|
||||
return EmojiSupport(not classic, "console")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def classic_console() -> bool:
|
||||
"""Whether this session is drawing into a genuine ``conhost`` window.
|
||||
|
||||
The one host with no font fallback of its own, so the only one where the configured
|
||||
font's own repertoire decides what the reader sees. See :func:`_is_classic_console`
|
||||
for how it is identified, and why not from the environment.
|
||||
"""
|
||||
return _is_classic_console() is True
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def emoji_support() -> EmojiSupport:
|
||||
"""Whether emoji icons can be drawn here, decided once and cached.
|
||||
|
||||
The ladder: the ``MESHTERM_EMOJI`` override, then everything that isn't Windows (where
|
||||
a UTF-8 terminal draws them as a matter of course), then :func:`_is_classic_console`.
|
||||
Cached because a session does not change host halfway through.
|
||||
|
||||
Returns:
|
||||
The :class:`EmojiSupport` verdict.
|
||||
"""
|
||||
return _emoji_support(os.environ)
|
||||
|
||||
|
||||
def emoji_enabled() -> bool:
|
||||
"""Whether icons should render as emoji rather than through the compact table."""
|
||||
return emoji_support().supported
|
||||
|
||||
|
||||
# --- chart glyphs -----------------------------------------------------------------
|
||||
|
||||
|
||||
#: Families measured to carry the Braille Patterns block (U+2800–U+28FF) that
|
||||
#: :mod:`meshterm.ui.braillechart` draws every timeline from.
|
||||
#:
|
||||
#: The list is short on purpose, and each entry is a measurement rather than a
|
||||
#: reputation: reading the ``cmap`` of every font installed on a development machine
|
||||
#: (2026-09-07) found that nearly no monospace font carries the block at all. Hack Nerd
|
||||
#: Font, JetBrains Mono, Fira Code, Source Code Pro, Consolas, Lucida Console and — the
|
||||
#: surprise — DejaVu Sans *Mono* all measure zero of 256. DejaVu *Sans*, which is
|
||||
#: proportional, carries all 256, which is where the widespread "install DejaVu for
|
||||
#: braille" advice comes from: it works only because terminals fall back from the mono
|
||||
#: face to the proportional one for that block.
|
||||
#:
|
||||
#: Which is the point. On every host that falls back — Windows Terminal, VS Code's
|
||||
#: terminal, macOS, Linux — the charts draw whatever the chosen font holds, so this list
|
||||
#: never has to be right. It matters on the one host with no fallback at all, the classic
|
||||
#: Windows console, where a font outside this list means boxes where the charts should be.
|
||||
CHART_FONTS: tuple[str, ...] = (
|
||||
# Microsoft's console font: 44/44, and the family MeshTerm bundles (see
|
||||
# meshterm.core.consolefont). Covers the PL and NF builds by prefix.
|
||||
"cascadia mono",
|
||||
"cascadia code",
|
||||
# The Nerd Font patches of the same, which keep what they patch.
|
||||
"caskaydiacove",
|
||||
"caskaydiamono",
|
||||
# Widely reported to carry the block; not measured here, no copy to hand.
|
||||
"iosevka",
|
||||
)
|
||||
|
||||
|
||||
def face_draws_charts(face: str | None) -> bool:
|
||||
"""Whether ``face`` can draw the braille the timelines are made of.
|
||||
|
||||
Args:
|
||||
face: A configured font family, in any spelling, or ``None``.
|
||||
|
||||
Returns:
|
||||
Whether it is one of :data:`CHART_FONTS`.
|
||||
"""
|
||||
if not face:
|
||||
return False
|
||||
normal = normalize_face(face)
|
||||
return any(normal.startswith(known) for known in CHART_FONTS)
|
||||
|
||||
|
||||
def installed_chart_font() -> str | None:
|
||||
"""The first installed family that can draw the charts, or ``None``.
|
||||
|
||||
What separates "select the font they already have" — every Windows 11 machine, and
|
||||
anyone who has installed Windows Terminal — from "offer to install ours".
|
||||
|
||||
Returns:
|
||||
The family name as installed, or ``None`` when nothing installed can do it.
|
||||
"""
|
||||
for family in _installed_families():
|
||||
if face_draws_charts(family):
|
||||
return family
|
||||
return None
|
||||
@@ -89,6 +89,8 @@ from collections.abc import Callable
|
||||
|
||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||
|
||||
from ...core import win32dll
|
||||
|
||||
#: The zero-width joiner: the tell that the codepoints it sits between are **one glyph**
|
||||
#: (``🤷♂️``, ``👨👩👧``), which a terminal draws in the width of the sequence's base.
|
||||
_ZWJ = ""
|
||||
@@ -309,7 +311,10 @@ def _win_probe_width(probe: str) -> int | None:
|
||||
("dwMaximumWindowSize", _COORD),
|
||||
]
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
# A private handle: declaring these signatures on `ctypes.windll.kernel32` would
|
||||
# rewrite them for prompt_toolkit too, which calls the same function object with a
|
||||
# struct class of its own. See meshterm.core.win32dll.
|
||||
kernel32 = win32dll.kernel32()
|
||||
# Declare signatures explicitly. Without this ctypes assumes a 32-bit int
|
||||
# return, which truncates the 64-bit console HANDLE and makes every
|
||||
# subsequent call fail — silently reducing the probe to "no measurement".
|
||||
|
||||
+19
-11
@@ -29,6 +29,7 @@ from prompt_toolkit.utils import get_cwidth
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from ...core import win32dll
|
||||
from ...platforms import get_platform
|
||||
from ...services import modifier_watch
|
||||
from . import fastrender, fkeys, frame
|
||||
@@ -168,9 +169,7 @@ def _right_ctrl_down() -> bool:
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
return bool(ctypes.windll.user32.GetAsyncKeyState(0xA3) & 0x8000) # VK_RCONTROL
|
||||
return bool(win32dll.user32().GetAsyncKeyState(0xA3) & 0x8000) # VK_RCONTROL
|
||||
except Exception: # noqa: BLE001 - a failed probe just leaves the plain action
|
||||
return False
|
||||
|
||||
@@ -191,8 +190,10 @@ def _read_clipboard() -> str:
|
||||
import ctypes
|
||||
|
||||
CF_UNICODETEXT = 13
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
# Private handles; the argtypes below would otherwise be declared on objects
|
||||
# every library in the process shares. See meshterm.core.win32dll.
|
||||
user32 = win32dll.user32()
|
||||
kernel32 = win32dll.kernel32()
|
||||
user32.GetClipboardData.restype = ctypes.c_void_p
|
||||
user32.GetClipboardData.argtypes = [ctypes.c_uint]
|
||||
kernel32.GlobalLock.restype = ctypes.c_void_p
|
||||
@@ -282,13 +283,20 @@ def _has_wide_glyph(text: str) -> bool:
|
||||
``>= 0x1100`` guard skips the ASCII/Latin bulk of a frame before the width lookup, which
|
||||
matters because this runs over the whole composed frame on every repaint.
|
||||
|
||||
On a platform that draws no emoji the answer is ``False`` by construction — its console
|
||||
font is a fixed 512-glyph set with no wide glyph in it, so nothing a frame can contain
|
||||
would return ``True``. Answering from the platform instead of the text skips a
|
||||
per-character scan of the entire frame on every repaint, and with it the scrub machinery
|
||||
that a ``True`` would arm, leaving the cheap differential paint permanently in play.
|
||||
On a platform that folds its frames to a fixed font the answer is ``False`` by
|
||||
construction — that font is a 512-glyph set with no wide glyph in it, so nothing a
|
||||
composed frame can contain would return ``True``. Answering from the platform instead
|
||||
of the text skips a per-character scan of the entire frame on every repaint, and with
|
||||
it the scrub machinery that a ``True`` would arm, leaving the cheap differential paint
|
||||
permanently in play.
|
||||
|
||||
The fold is the guarantee, not the icon table: a terminal that merely can't draw
|
||||
*emoji* (the classic Windows console — see :func:`meshterm.ui.termfont.emoji_support`)
|
||||
still renders the rest of the BMP, and a contact named in CJK is two cells wide there
|
||||
like anywhere else. Reading ``emoji`` here instead would have skipped the scan on a
|
||||
frame that genuinely needed it, and smeared the row.
|
||||
"""
|
||||
if not get_platform().emoji:
|
||||
if get_platform().ascii_fold:
|
||||
return False
|
||||
return any(ord(ch) >= 0x1100 and get_cwidth(ch) == 2 for ch in text)
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Launcher for the frozen builds.
|
||||
|
||||
PyInstaller freezes a *script*, not a console-script entry point, so this is the script:
|
||||
it does what the ``meshterm`` command does and nothing else. Kept in ``packaging/``
|
||||
rather than in the package, because it exists for the installers and has no business
|
||||
being importable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Windows and macOS spawn child processes by re-running this executable. Without
|
||||
# this call a frozen app that ever starts one forks a second copy of the whole UI
|
||||
# instead, which looks like the app launching itself over and over.
|
||||
multiprocessing.freeze_support()
|
||||
|
||||
from meshterm.cli import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
# PyInstaller spec for the downloadable builds. Run from the repository root:
|
||||
#
|
||||
# pyinstaller packaging/meshterm.spec
|
||||
#
|
||||
# One file, console mode. Console is not a detail: MeshTerm *is* a terminal program, and
|
||||
# a windowed build would start with nowhere to draw and exit immediately.
|
||||
#
|
||||
# Built on the machine it targets. There is no cross-compiling here — the Windows build
|
||||
# comes off a Windows runner, the macOS one off macOS. That is what the workflow is for.
|
||||
|
||||
from PyInstaller.utils.hooks import collect_submodules
|
||||
|
||||
# The assets have to land at `meshterm/assets`, because `ui/about.py` finds them with
|
||||
# `Path(__file__).parent.parent / "assets"` and that path has to keep resolving inside
|
||||
# the bundle exactly as it does in a checkout.
|
||||
datas = [
|
||||
("../meshterm/assets", "meshterm/assets"),
|
||||
]
|
||||
|
||||
hiddenimports = [
|
||||
# Every tool module. `meshterm.tools` finds these with `pkgutil.iter_modules` at
|
||||
# import time, so nothing static ever names one, and a frozen build without this line
|
||||
# starts with an empty registry: no menu entries but Quit, and no CLI subcommands.
|
||||
# The app launches and looks like it works, which is the worst way for this to fail.
|
||||
*collect_submodules("meshterm.tools"),
|
||||
# bleak picks its backend at runtime by platform, so static analysis never sees the
|
||||
# one that actually gets used. Collect all of them and let the unused ones sit.
|
||||
*collect_submodules("bleak.backends"),
|
||||
# Same story for the companion library's transports.
|
||||
*collect_submodules("meshcore"),
|
||||
# Rich and prompt_toolkit both reach for things dynamically.
|
||||
"rich.console",
|
||||
"prompt_toolkit.output.win32",
|
||||
"prompt_toolkit.output.vt100",
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
["entry.py"],
|
||||
pathex=[".."],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
# Nothing here is used, and each one drags in a GUI toolkit or a test framework.
|
||||
excludes=["tkinter", "pytest", "IPython", "matplotlib", "numpy", "PIL"],
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name="meshterm",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
# UPX is off deliberately: it saves a few megabytes and gets the result flagged by
|
||||
# antivirus often enough that it is not a trade worth making for a first release.
|
||||
upx=False,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
+38
-5
@@ -3,14 +3,34 @@ requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "meshterm"
|
||||
# The distribution name only. The import package and the console script both stay
|
||||
# `meshterm` — PyPI prohibits that name (see pypi/support#12162), and nothing about the
|
||||
# app changes because of it. `[tool.hatch.build.targets.wheel]` below is what keeps the
|
||||
# wheel shipping the `meshterm/` package under this different name.
|
||||
name = "mesh-term"
|
||||
dynamic = ["version"]
|
||||
description = "MeshTerm is a full-featured TUI-based MeshCore client for your terminal. Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Linux."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Jean-Pierre Martineau" }]
|
||||
license = { text = "Apache-2.0" }
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE", "NOTICE"]
|
||||
keywords = ["meshcore", "lora", "mesh", "radio", "cli"]
|
||||
# No `License ::` entry: PEP 639 forbids one alongside the SPDX expression above.
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Operating System :: MacOS",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Communications :: Ham Radio",
|
||||
"Topic :: System :: Networking",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"meshcore>=2.3", # companion-device protocol (serial/BLE/TCP)
|
||||
@@ -31,9 +51,18 @@ dependencies = [
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.4",
|
||||
# Pinned to a minor line, not floated. A formatter that drifts between machines is
|
||||
# not a gate: 0.16 formats Python inside Markdown and 0.15 does not, so an
|
||||
# unpinned `ruff>=0.4` had CI and a laptop disagreeing about a clean tree.
|
||||
"ruff>=0.16.6,<0.17",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/jpmartineau/MeshTerm"
|
||||
Repository = "https://github.com/jpmartineau/MeshTerm"
|
||||
Issues = "https://github.com/jpmartineau/MeshTerm/issues"
|
||||
Changelog = "https://github.com/jpmartineau/MeshTerm/blob/main/CHANGELOG.md"
|
||||
|
||||
[project.scripts]
|
||||
meshterm = "meshterm.cli:main"
|
||||
|
||||
@@ -48,8 +77,12 @@ packages = ["meshterm"]
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
# Helper scripts for the Claude skills; they are not part of the package.
|
||||
extend-exclude = [".claude"]
|
||||
# Helper scripts for the Claude skills; they are not part of the package. And `docs/`,
|
||||
# which holds dated survey documents: ruff formats Python blocks inside Markdown, and the
|
||||
# code they quote is an excerpt of how a file looked on a past date. Reformatting a
|
||||
# quotation edits the record — and the excerpts are call fragments, not whole programs, so
|
||||
# the formatter's idea of "fixed" turns them into something that was never there.
|
||||
extend-exclude = [".claude", "docs"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "D"]
|
||||
|
||||
+30
-1
@@ -8,7 +8,9 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from meshterm.core.config import Settings
|
||||
import pytest
|
||||
|
||||
from meshterm.core.config import CONFIG_DIR_ENV, Settings, default_config_dir
|
||||
|
||||
|
||||
def test_connect_on_start_defaults_true() -> None:
|
||||
@@ -42,3 +44,30 @@ def test_tcp_profile_defaults_port(tmp_path: Path) -> None:
|
||||
config.write_text('[profiles.wifi]\nhost = "meshcore.local"\n', encoding="utf-8")
|
||||
profile = Settings.load(config).profiles["wifi"]
|
||||
assert profile.is_tcp and profile.tcp_endpoint == "meshcore.local:5000"
|
||||
|
||||
|
||||
def test_config_dir_defaults_to_the_home_directory(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""With no override set, config and data live in ``.meshterm`` under the user's home."""
|
||||
monkeypatch.delenv(CONFIG_DIR_ENV, raising=False)
|
||||
assert default_config_dir() == Path.home() / ".meshterm"
|
||||
|
||||
|
||||
def test_config_dir_follows_the_override(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""``$MESHTERM_HOME`` moves the whole directory, so a build can run beside a real one.
|
||||
|
||||
This is what keeps a downloaded binary from opening the same database as the checkout
|
||||
it was built from — the history is the valuable half of an install, and there was no
|
||||
way to point a second copy somewhere else.
|
||||
"""
|
||||
monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path / "elsewhere"))
|
||||
assert default_config_dir() == tmp_path / "elsewhere"
|
||||
|
||||
|
||||
def test_config_dir_ignores_an_empty_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""An unset-but-present or whitespace value falls back rather than resolving to nowhere.
|
||||
|
||||
A shell that exports the variable empty is common enough that treating "" as "put the
|
||||
data in the current directory" would be a nasty way to scatter someone's history.
|
||||
"""
|
||||
monkeypatch.setenv(CONFIG_DIR_ENV, " ")
|
||||
assert default_config_dir() == Path.home() / ".meshterm"
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Tests for the bundled console font and the machinery that installs and selects it.
|
||||
|
||||
The Win32 half cannot be exercised off Windows, and should not be exercised *on* it by a
|
||||
test suite — installing a font is a change to the machine, and one that cannot be undone
|
||||
inside a session (Windows locks the file once it is loaded). So what is pinned here is
|
||||
everything that can be checked without touching the system: that the font we ship is the
|
||||
font we say we ship, that it can actually draw what MeshTerm draws, and that every entry
|
||||
point degrades rather than raises where there is no console to talk to.
|
||||
|
||||
The font's own coverage is asserted from its ``cmap``, which makes this the desktop
|
||||
counterpart to :mod:`meshterm.ui.fontset` — the PicoCalc's device-verified inventory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from meshterm.core import consolefont
|
||||
from meshterm.ui.termfont import CHART_FONTS, face_draws_charts, installed_chart_font
|
||||
|
||||
BRAILLE = range(0x2800, 0x2900)
|
||||
|
||||
|
||||
def _cmap(path: Path) -> set[int]:
|
||||
"""Every codepoint a TrueType font maps, read from its ``cmap`` table.
|
||||
|
||||
A short format-4 reader rather than a dependency: the question asked here is narrow
|
||||
and the alternative is trusting a font file we ship without ever looking inside it.
|
||||
"""
|
||||
data = path.read_bytes()
|
||||
count = struct.unpack(">H", data[4:6])[0]
|
||||
tables = {}
|
||||
for i in range(count):
|
||||
offset = 12 + i * 16
|
||||
tag, _, start, _length = struct.unpack(">4sIII", data[offset : offset + 16])
|
||||
tables[tag.decode("latin-1")] = start
|
||||
|
||||
cmap = tables["cmap"]
|
||||
covered: set[int] = set()
|
||||
for i in range(struct.unpack(">H", data[cmap + 2 : cmap + 4])[0]):
|
||||
record = cmap + 4 + i * 8
|
||||
sub = cmap + struct.unpack(">HHI", data[record : record + 8])[2]
|
||||
if struct.unpack(">H", data[sub : sub + 2])[0] != 4:
|
||||
continue
|
||||
seg_bytes = struct.unpack(">H", data[sub + 6 : sub + 8])[0]
|
||||
segs = seg_bytes // 2
|
||||
ends = struct.unpack(f">{segs}H", data[sub + 14 : sub + 14 + seg_bytes])
|
||||
starts_at = sub + 16 + seg_bytes
|
||||
starts = struct.unpack(f">{segs}H", data[starts_at : starts_at + seg_bytes])
|
||||
deltas_at = starts_at + seg_bytes
|
||||
deltas = struct.unpack(f">{segs}h", data[deltas_at : deltas_at + seg_bytes])
|
||||
ranges_at = deltas_at + seg_bytes
|
||||
ranges = struct.unpack(f">{segs}H", data[ranges_at : ranges_at + seg_bytes])
|
||||
for seg in range(segs):
|
||||
for cp in range(starts[seg], min(ends[seg], 0xFFFF) + 1):
|
||||
if ranges[seg] == 0:
|
||||
glyph = (cp + deltas[seg]) & 0xFFFF
|
||||
else:
|
||||
at = ranges_at + seg * 2 + ranges[seg] + (cp - starts[seg]) * 2
|
||||
if at + 2 > len(data):
|
||||
continue
|
||||
glyph = struct.unpack(">H", data[at : at + 2])[0]
|
||||
if glyph:
|
||||
glyph = (glyph + deltas[seg]) & 0xFFFF
|
||||
if glyph:
|
||||
covered.add(cp)
|
||||
return covered
|
||||
|
||||
|
||||
def test_the_font_we_ship_is_present_with_its_licence() -> None:
|
||||
"""It is redistributed under the SIL OFL, which travels with the file or not at all."""
|
||||
assert consolefont.BUNDLED_FONT.is_file()
|
||||
licence = consolefont.FONT_DIR / "CascadiaMono-OFL.txt"
|
||||
assert licence.is_file()
|
||||
text = licence.read_text(encoding="utf-8")
|
||||
assert "SIL Open Font License" in text
|
||||
assert "Reserved Font Name" in text
|
||||
|
||||
|
||||
def test_the_bundled_font_can_draw_the_charts() -> None:
|
||||
"""The reason this font and not another: it has the braille block, and few do.
|
||||
|
||||
Measured across every font on a development machine, no mainstream coder font carried
|
||||
it — Hack Nerd Font, JetBrains Mono, Fira Code, Source Code Pro and even DejaVu Sans
|
||||
*Mono* all hold none of the 256. Shipping one that cannot draw a chart would leave the
|
||||
offer technically successful and visibly pointless.
|
||||
"""
|
||||
covered = _cmap(consolefont.BUNDLED_FONT)
|
||||
missing = [cp for cp in BRAILLE if cp not in covered]
|
||||
assert not missing, f"{len(missing)} braille cells missing from the bundled font"
|
||||
|
||||
|
||||
def test_the_bundled_font_covers_the_marks_and_the_path_chips() -> None:
|
||||
"""The rest of what a classic console has to draw from its font alone.
|
||||
|
||||
The powerline separators are why the ``PL`` build is the one bundled rather than the
|
||||
plain one: 25KB more, and the path lines keep their chips.
|
||||
"""
|
||||
covered = _cmap(consolefont.BUNDLED_FONT)
|
||||
for mark in "✓❯◉●○▲■─│╭╮╰╯▌▐░▒▓█←↑→↓↔↕…":
|
||||
assert ord(mark) in covered, f"{mark!r} is not in the bundled font"
|
||||
for chip in "": # the powerline separator and its round caps
|
||||
assert ord(chip) in covered, f"U+{ord(chip):04X} is not in the bundled font"
|
||||
|
||||
|
||||
def test_the_bundled_face_is_what_the_font_calls_itself() -> None:
|
||||
"""``SetCurrentConsoleFontEx`` matches on the family name exactly, so a typo is silent.
|
||||
|
||||
It would install the font, fail to select it, and report a console that "kept its own
|
||||
font" — with nothing anywhere naming the real cause.
|
||||
"""
|
||||
data = consolefont.BUNDLED_FONT.read_bytes()
|
||||
count = struct.unpack(">H", data[4:6])[0]
|
||||
name_table = next(
|
||||
struct.unpack(">4sIII", data[12 + i * 16 : 28 + i * 16])[2]
|
||||
for i in range(count)
|
||||
if struct.unpack(">4sIII", data[12 + i * 16 : 28 + i * 16])[0] == b"name"
|
||||
)
|
||||
records, strings = struct.unpack(">HH", data[name_table + 2 : name_table + 6])
|
||||
families = set()
|
||||
for i in range(records):
|
||||
at = name_table + 6 + i * 12
|
||||
platform, encoding, language, name_id, length, offset = struct.unpack(
|
||||
">HHHHHH", data[at : at + 12]
|
||||
)
|
||||
if (platform, encoding, language, name_id) == (3, 1, 0x409, 1):
|
||||
start = name_table + strings + offset
|
||||
families.add(data[start : start + length].decode("utf-16-be"))
|
||||
assert consolefont.BUNDLED_FACE in families, f"font calls itself {families}"
|
||||
|
||||
|
||||
def test_the_bundled_face_is_one_we_would_accept() -> None:
|
||||
"""The font we install has to satisfy the check that decides whether to offer it.
|
||||
|
||||
Otherwise MeshTerm installs it, selects it, and offers again on the next launch.
|
||||
"""
|
||||
assert face_draws_charts(consolefont.BUNDLED_FACE)
|
||||
|
||||
|
||||
def test_chart_fonts_are_matched_by_family_however_spelled() -> None:
|
||||
"""Cascadia ships plain, PL and NF builds, and the Nerd Font patches rename it.
|
||||
|
||||
Matched on the *family* name, which is what a terminal's config, the ``HKCU``
|
||||
registration and ``SetCurrentConsoleFontEx`` all speak — never the filename. Those
|
||||
differ here: ``CascadiaMonoPL.ttf`` calls itself ``Cascadia Mono PL``, spaced.
|
||||
"""
|
||||
assert face_draws_charts("Cascadia Mono")
|
||||
assert face_draws_charts("Cascadia Mono PL")
|
||||
assert face_draws_charts("Cascadia Mono NF")
|
||||
assert face_draws_charts(" cascadia code ") # spacing and case are normalised
|
||||
assert face_draws_charts("CaskaydiaCove Nerd Font Mono")
|
||||
|
||||
|
||||
def test_the_fonts_that_cannot_draw_charts_are_not_claimed() -> None:
|
||||
"""Every one of these was measured at zero braille cells; a wrong yes here is a lie.
|
||||
|
||||
It would leave a reader on a classic console with boxes for charts and no offer to
|
||||
fix them, which is the exact failure the whole check exists to prevent.
|
||||
"""
|
||||
for face in (
|
||||
"Consolas",
|
||||
"Lucida Console",
|
||||
"Courier New",
|
||||
"Hack Nerd Font Mono",
|
||||
"JetBrains Mono",
|
||||
"Fira Code",
|
||||
"Source Code Pro",
|
||||
"DejaVu Sans Mono",
|
||||
):
|
||||
assert not face_draws_charts(face), f"{face} does not carry the braille block"
|
||||
assert not face_draws_charts(None)
|
||||
assert not face_draws_charts("")
|
||||
|
||||
|
||||
def test_every_chart_font_entry_is_normalised() -> None:
|
||||
"""The list is compared against normalised faces, so an entry with capitals never hits."""
|
||||
for entry in CHART_FONTS:
|
||||
assert entry == entry.lower().strip()
|
||||
assert " " not in entry
|
||||
|
||||
|
||||
def test_asking_the_machine_what_it_has_never_raises() -> None:
|
||||
"""Best-effort, on every platform: a font scan that throws would take startup with it."""
|
||||
assert installed_chart_font() is None or isinstance(installed_chart_font(), str)
|
||||
|
||||
|
||||
def test_reading_the_console_font_never_raises() -> None:
|
||||
"""Under pytest there is no console worth asking, and the answer must simply be None."""
|
||||
assert consolefont.current_face() is None or isinstance(consolefont.current_face(), str)
|
||||
|
||||
|
||||
def test_the_user_font_directory_is_under_the_users_own_profile() -> None:
|
||||
"""The whole point of the per-user location: no administrator rights are involved."""
|
||||
directory = consolefont.user_font_dir()
|
||||
assert directory.parts[-3:] == ("Microsoft", "Windows", "Fonts")
|
||||
assert "AppData" in str(directory) or "Local" in str(directory)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="would touch the real machine")
|
||||
def test_installing_is_a_no_op_where_there_are_no_windows_fonts() -> None:
|
||||
"""Off Windows the offer is never made, and the install refuses rather than pretends."""
|
||||
assert consolefont.install_bundled_font() is False
|
||||
assert consolefont.select("Cascadia Mono PL") is False
|
||||
assert consolefont.use("Cascadia Mono PL") is False
|
||||
|
||||
|
||||
def test_a_font_resource_is_only_added_from_a_file_that_exists(tmp_path: Path) -> None:
|
||||
"""The retry path takes a path that may well not be there, and must not raise on it."""
|
||||
assert consolefont._add_font_resource(tmp_path / "nothing.ttf") is False
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for the one-interactive-session-per-directory guard, and the atomic writer.
|
||||
|
||||
Both exist for the same reason: every copy of MeshTerm on a machine shares one data
|
||||
directory unless told otherwise, so two of them running at once is a thing that happens
|
||||
by accident rather than by choice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from meshterm.core.atomicwrite import write_atomically
|
||||
from meshterm.core.instancelock import LOCK_NAME, InstanceBusy, hold_instance_lock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_the_lock_is_released_when_the_holder_leaves(tmp_path: Path) -> None:
|
||||
"""Two sessions in sequence are fine; the guard is against overlap, not against reuse."""
|
||||
with hold_instance_lock(tmp_path):
|
||||
pass
|
||||
with hold_instance_lock(tmp_path):
|
||||
pass
|
||||
|
||||
|
||||
def test_the_lock_file_lives_beside_the_data(tmp_path: Path) -> None:
|
||||
"""The lock sits in the directory it guards, and the directory is made if missing."""
|
||||
target = tmp_path / "not-yet"
|
||||
with hold_instance_lock(target):
|
||||
assert (target / LOCK_NAME).exists()
|
||||
|
||||
|
||||
def test_a_second_process_is_turned_away(tmp_path: Path) -> None:
|
||||
"""The real test of an OS lock: another *process* cannot take it.
|
||||
|
||||
Nothing in-process proves anything here — a lock that only excluded the thread that
|
||||
already holds it would pass a single-process test and fail in the one situation it
|
||||
exists for.
|
||||
"""
|
||||
program = (
|
||||
"import pathlib, sys\n"
|
||||
f"sys.path.insert(0, {str(REPO_ROOT)!r})\n"
|
||||
"from meshterm.core.instancelock import hold_instance_lock, InstanceBusy\n"
|
||||
"try:\n"
|
||||
f" with hold_instance_lock(pathlib.Path({str(tmp_path)!r})):\n"
|
||||
" print('TAKEN')\n"
|
||||
"except InstanceBusy:\n"
|
||||
" print('REFUSED')\n"
|
||||
)
|
||||
with hold_instance_lock(tmp_path):
|
||||
done = subprocess.run(
|
||||
[sys.executable, "-c", program], capture_output=True, text=True, timeout=60
|
||||
)
|
||||
assert done.stdout.strip() == "REFUSED", done.stderr[-500:]
|
||||
|
||||
|
||||
def test_the_refusal_says_how_to_run_two(tmp_path: Path) -> None:
|
||||
"""The message is mostly the way out, because that is what the reader needs next.
|
||||
|
||||
Someone who sees this is doing something reasonable — trying a downloaded build, or
|
||||
running a second radio — so being told *no* without being told *how* would be the
|
||||
unhelpful half of the answer.
|
||||
"""
|
||||
message = str(InstanceBusy(tmp_path))
|
||||
assert "MESHTERM_HOME" in message
|
||||
assert str(tmp_path) in message
|
||||
|
||||
|
||||
def test_a_write_lands_whole_or_not_at_all(tmp_path: Path) -> None:
|
||||
"""The written file holds exactly what was asked for, and no scratch file survives."""
|
||||
target = tmp_path / "sub" / "contacts.json"
|
||||
write_atomically(target, json.dumps({"a": 1}))
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {"a": 1}
|
||||
assert list(tmp_path.rglob("*.tmp")) == []
|
||||
|
||||
|
||||
def test_the_scratch_file_is_named_for_its_writer(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Two processes writing the same file must not share one temporary name.
|
||||
|
||||
The rename is atomic; the thing being renamed is not. A fixed ``.tmp`` name lets two
|
||||
writers interleave inside one scratch file and then each rename whatever ended up
|
||||
there over the real one.
|
||||
"""
|
||||
seen: list[Path] = []
|
||||
real = Path.write_text
|
||||
|
||||
def spy(self: Path, *args: object, **kwargs: object) -> int:
|
||||
seen.append(self)
|
||||
return real(self, *args, **kwargs) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr(Path, "write_text", spy)
|
||||
write_atomically(tmp_path / "x.json", "{}")
|
||||
assert seen and str(__import__("os").getpid()) in seen[0].name
|
||||
|
||||
|
||||
def test_a_failed_write_leaves_no_litter(tmp_path: Path, monkeypatch) -> None:
|
||||
"""A write that raises cleans up after itself rather than leaving a partial file."""
|
||||
|
||||
def boom(self: Path, *args: object, **kwargs: object) -> int:
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(Path, "write_text", boom)
|
||||
with pytest.raises(OSError):
|
||||
write_atomically(tmp_path / "y.json", "{}")
|
||||
assert list(tmp_path.rglob("*.tmp")) == []
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for the log file: plain text, bounded, and holding the things that went wrong.
|
||||
|
||||
The log's one real job is being opened by a person who has hit a problem — often the
|
||||
person reporting it. Every assertion here is about that reader.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from meshterm.persistence.logging import (
|
||||
LOG_FILENAME,
|
||||
configure_logging,
|
||||
get_logger,
|
||||
level_from_name,
|
||||
log_path,
|
||||
)
|
||||
|
||||
|
||||
def _read(log_dir: Path) -> str:
|
||||
for handler in get_logger().handlers:
|
||||
handler.flush()
|
||||
return log_path(log_dir).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_the_log_is_plain_text(tmp_path: Path) -> None:
|
||||
"""One readable line per record: when, how bad, who said it, and what."""
|
||||
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
|
||||
get_logger("core.connection").warning("link lost, retrying")
|
||||
line = _read(tmp_path).splitlines()[0]
|
||||
assert "WARNING" in line
|
||||
assert "meshterm.core.connection" in line
|
||||
assert "link lost, retrying" in line
|
||||
# A date as well as a time. "it happened at 14:40" is useless a week later.
|
||||
assert line.startswith("20")
|
||||
|
||||
|
||||
def test_the_file_is_named_plainly(tmp_path: Path) -> None:
|
||||
"""``meshterm.log`` — an extension every text editor already opens."""
|
||||
assert log_path(tmp_path).name == LOG_FILENAME == "meshterm.log"
|
||||
|
||||
|
||||
def test_the_level_keeps_the_quiet_records_out(tmp_path: Path) -> None:
|
||||
"""At the default, the file holds problems rather than a narration of a working run.
|
||||
|
||||
This is the whole point of the default being WARNING: a log where the dozen useful
|
||||
lines sit under twenty thousand dull ones is one nobody reads.
|
||||
"""
|
||||
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
|
||||
log = get_logger()
|
||||
log.debug("opened a screen")
|
||||
log.info("connected to a radio")
|
||||
log.warning("something went wrong")
|
||||
written = _read(tmp_path)
|
||||
assert "something went wrong" in written
|
||||
assert "opened a screen" not in written
|
||||
assert "connected to a radio" not in written
|
||||
|
||||
|
||||
def test_turning_it_up_lets_everything_through(tmp_path: Path) -> None:
|
||||
"""DEBUG is there for chasing something, and keeps what WARNING drops."""
|
||||
configure_logging(Console(quiet=True), tmp_path, file_level=logging.DEBUG, quiet=True)
|
||||
get_logger().debug("opened a screen")
|
||||
assert "opened a screen" in _read(tmp_path)
|
||||
|
||||
|
||||
def test_a_traceback_goes_in_with_the_record(tmp_path: Path) -> None:
|
||||
"""An exception's traceback is written under the record that reports it.
|
||||
|
||||
This is most of why the file is worth attaching to a bug report: the terminal scrolls
|
||||
away and gets closed, and this copy does not.
|
||||
"""
|
||||
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
|
||||
try:
|
||||
raise ValueError("the device said something impossible")
|
||||
except ValueError:
|
||||
get_logger().exception("tool raised")
|
||||
written = _read(tmp_path)
|
||||
assert "tool raised" in written
|
||||
assert "ValueError: the device said something impossible" in written
|
||||
assert "Traceback (most recent call last)" in written
|
||||
|
||||
|
||||
def test_the_file_is_bounded(tmp_path: Path) -> None:
|
||||
"""It rotates. An unbounded log reached 11MB in ten weeks on a real install."""
|
||||
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
|
||||
handler = next(h for h in get_logger().handlers if hasattr(h, "maxBytes"))
|
||||
assert handler.maxBytes > 0
|
||||
assert handler.backupCount > 0
|
||||
|
||||
|
||||
def test_a_level_name_becomes_a_level(tmp_path: Path) -> None:
|
||||
"""The preference stores a name; the handler needs a number."""
|
||||
assert level_from_name("DEBUG") == logging.DEBUG
|
||||
assert level_from_name("warning") == logging.WARNING
|
||||
assert level_from_name(" Error ") == logging.ERROR
|
||||
|
||||
|
||||
def test_a_nonsense_level_still_starts_the_app() -> None:
|
||||
"""A typo in a hand-edited preferences file loses log detail, not the app.
|
||||
|
||||
Someone editing YAML by hand and writing ``WARN`` should get a slightly quieter log,
|
||||
not a program that refuses to launch.
|
||||
"""
|
||||
assert level_from_name("WARN") == logging.WARNING
|
||||
assert level_from_name("") == logging.WARNING
|
||||
+58
-1
@@ -6,12 +6,20 @@ sanity — PicoCalc being strictly the narrower, plainer flavour.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import meshterm.platforms as platforms_mod
|
||||
from meshterm.platforms import PICOCALC, REGULAR, get_platform, resolve, set_platform
|
||||
from meshterm.platforms import (
|
||||
PICOCALC,
|
||||
REGULAR,
|
||||
get_platform,
|
||||
resolve,
|
||||
set_platform,
|
||||
without_emoji,
|
||||
)
|
||||
|
||||
|
||||
def test_regular_is_todays_behaviour() -> None:
|
||||
@@ -142,3 +150,52 @@ def test_resolve_auto_detect_is_conservative(monkeypatch: pytest.MonkeyPatch) ->
|
||||
r = resolve()
|
||||
assert r.platform is REGULAR
|
||||
assert r.detected_model == "Raspberry Pi 4 Model B"
|
||||
|
||||
|
||||
# -- the emoji-less variant ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_without_emoji_moves_one_flag_and_nothing_else() -> None:
|
||||
"""A terminal that can't draw emoji is not a different flavour.
|
||||
|
||||
The classic Windows console has the desktop's width, colour depth, borders and
|
||||
keyboard — only the icons have to change. Every other field must survive, and
|
||||
``ascii_fold`` above all: folding there would strip accents from names the console
|
||||
renders perfectly well.
|
||||
"""
|
||||
plain = without_emoji(REGULAR)
|
||||
assert plain.emoji is False
|
||||
assert plain.ascii_fold is False
|
||||
assert plain.truecolor is True
|
||||
assert plain.readable_cols == REGULAR.readable_cols
|
||||
assert plain.footer_fkeys is REGULAR.footer_fkeys
|
||||
|
||||
changed = {
|
||||
f.name
|
||||
for f in dataclasses.fields(REGULAR)
|
||||
if getattr(plain, f.name) != getattr(REGULAR, f.name)
|
||||
}
|
||||
assert changed == {"emoji"}
|
||||
|
||||
|
||||
def test_without_emoji_leaves_an_already_compact_platform_alone() -> None:
|
||||
"""A platform whose icons are already compact comes back untouched.
|
||||
|
||||
The same object, not a copy that would compare equal but break every ``is`` check
|
||||
the suite makes against PICOCALC.
|
||||
"""
|
||||
assert without_emoji(PICOCALC) is PICOCALC
|
||||
|
||||
|
||||
def test_the_emoji_less_variant_still_drives_the_theme() -> None:
|
||||
"""The derived platform is a real one: binding it routes icons through the table.
|
||||
|
||||
This is the whole point — the seam's flags are independent, so clearing one reaches
|
||||
the glyph funnel without dragging the console font's fold along with it.
|
||||
"""
|
||||
from meshterm.ui.theme import fold_text, glyph
|
||||
|
||||
set_platform(without_emoji(REGULAR))
|
||||
assert glyph("📡") == "☼" # the advert icon, compacted
|
||||
assert fold_text("café Montréal") == "café Montréal" # no fold
|
||||
assert get_platform().readable_cols == 72
|
||||
+86
-3
@@ -1,8 +1,9 @@
|
||||
"""Terminal-font detection tests: the recommended list, the ladder, the verdict.
|
||||
|
||||
Everything runs against injected environments and temp settings files — no test
|
||||
touches the real registry, the real Win32 console, or the machine's actual fonts —
|
||||
so the suite decides the same verdicts on any box.
|
||||
Every verdict runs against injected environments, temp settings files and injected
|
||||
probes, so the suite decides the same answers on any box. Two tests deliberately do
|
||||
touch the real machine — the installed-font scan and the console buffer probe — and both
|
||||
assert only that asking is safe, never what this particular box replies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,6 +18,8 @@ from meshterm.ui.termfont import (
|
||||
NONE,
|
||||
UNKNOWN,
|
||||
RecommendedFont,
|
||||
_emoji_support,
|
||||
_is_classic_console,
|
||||
_powerline_support,
|
||||
_read_jsonc,
|
||||
_vscode_default_face,
|
||||
@@ -252,3 +255,83 @@ def test_installed_recommended_never_raises() -> None:
|
||||
"""
|
||||
result = installed_recommended()
|
||||
assert result is None or isinstance(result, RecommendedFont)
|
||||
|
||||
|
||||
# --- emoji support ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_emoji_is_only_the_windows_consoles_problem() -> None:
|
||||
"""Everywhere but Windows, a terminal draws emoji and the question does not arise.
|
||||
|
||||
The probe is never even run there — a Linux or macOS session pays nothing for a limit
|
||||
only the classic console has.
|
||||
"""
|
||||
|
||||
def never_called() -> bool | None:
|
||||
raise AssertionError("the console probe must not run off Windows")
|
||||
|
||||
for system in ("linux", "darwin", "freebsd"):
|
||||
verdict = _emoji_support({}, console_probe=never_called, system=system)
|
||||
assert (verdict.supported, verdict.source) == (True, "platform")
|
||||
|
||||
|
||||
def test_a_classic_console_turns_the_icons_compact() -> None:
|
||||
"""The verdict that matters: conhost draws no emoji, so the icons go compact.
|
||||
|
||||
This is the double-clicked build on Windows — the case the whole check exists for.
|
||||
"""
|
||||
verdict = _emoji_support({}, console_probe=lambda: True, system="win32")
|
||||
assert (verdict.supported, verdict.source) == (False, "console")
|
||||
|
||||
|
||||
def test_every_other_windows_terminal_keeps_its_emoji() -> None:
|
||||
"""Windows Terminal, VS Code's terminal and ssh all draw them; nothing changes."""
|
||||
verdict = _emoji_support({}, console_probe=lambda: False, system="win32")
|
||||
assert (verdict.supported, verdict.source) == (True, "console")
|
||||
|
||||
|
||||
def test_an_unanswerable_probe_never_degrades_the_ui() -> None:
|
||||
"""No console to identify (redirected output, a failed call) leaves emoji on.
|
||||
|
||||
An unknown is not evidence of a limit. Degrading on one would strip the icons from
|
||||
every piped or redirected run, which is most of the test suite and all of CI.
|
||||
"""
|
||||
verdict = _emoji_support({}, console_probe=lambda: None, system="win32")
|
||||
assert (verdict.supported, verdict.source) == (True, "no-console")
|
||||
|
||||
|
||||
def test_the_override_beats_the_probe_both_ways() -> None:
|
||||
"""``MESHTERM_EMOJI`` wins, as the powerline gate's override does.
|
||||
|
||||
Someone on a console this cannot recognise knows their own glass better than a probe
|
||||
does — and the negative direction is how anyone asks for the compact icons on purpose.
|
||||
"""
|
||||
forced_on = _emoji_support({"MESHTERM_EMOJI": "1"}, console_probe=lambda: True, system="win32")
|
||||
assert (forced_on.supported, forced_on.source) == (True, "env")
|
||||
|
||||
def never_called() -> bool | None:
|
||||
raise AssertionError("the override decides before the probe runs")
|
||||
|
||||
forced_off = _emoji_support({"MESHTERM_EMOJI": "0"}, console_probe=never_called, system="linux")
|
||||
assert (forced_off.supported, forced_off.source) == (False, "env")
|
||||
|
||||
|
||||
def test_an_inherited_wt_session_cannot_speak_for_the_host() -> None:
|
||||
"""The environment is not consulted, and this is why.
|
||||
|
||||
``WT_SESSION`` is inherited by child processes, so a program launched from Windows
|
||||
Terminal into a console of its own still carries it — measured, not supposed. Reading
|
||||
it would have answered for the terminal that did the launching.
|
||||
"""
|
||||
env = {"WT_SESSION": "670f6626-8bdf-4c09-9164-b1eef91abe4f"}
|
||||
verdict = _emoji_support(env, console_probe=lambda: True, system="win32")
|
||||
assert verdict.supported is False
|
||||
|
||||
|
||||
def test_identifying_the_host_never_raises() -> None:
|
||||
"""Whatever this machine is, asking is safe and answers one of three things.
|
||||
|
||||
It runs on a startup path, so "it degrades rather than raises" is the property that
|
||||
keeps an unusual host from taking the app down with it.
|
||||
"""
|
||||
assert _is_classic_console() in (True, False, None)
|
||||
@@ -150,6 +150,24 @@ def test_every_glyph_map_target_is_in_the_console_font() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_every_glyph_map_target_stays_inside_the_bmp() -> None:
|
||||
"""The table has a second consumer that the console font cannot speak for.
|
||||
|
||||
The classic Windows console keeps one 16-bit code unit per cell, so a character above
|
||||
U+FFFF is replaced by U+FFFD before its font is ever consulted — which is why MeshTerm
|
||||
routes icons through this table there too (see
|
||||
:func:`meshterm.ui.termfont.emoji_support`). A substitute "improved" into an emoji
|
||||
would draw fine on the PicoCalc's own font and silently break every Windows console,
|
||||
so the plane is asserted here rather than left to the font whitelist to imply.
|
||||
"""
|
||||
for emoji, compact in theme._GLYPH_MAP.items():
|
||||
for ch in compact:
|
||||
assert ord(ch) <= 0xFFFF, (
|
||||
f"{emoji!r} maps to {compact!r}; {ch!r} is above U+FFFF and no classic "
|
||||
f"Windows console can hold it"
|
||||
)
|
||||
|
||||
|
||||
def test_glyph_map_targets_never_widen_their_icon() -> None:
|
||||
"""A compact form at most matches its emoji's measured width (the fold pads the rest)."""
|
||||
for emoji, compact in theme._GLYPH_MAP.items():
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests for the private Windows DLL handles.
|
||||
|
||||
These pin a crash that only ever appeared in a frozen build on Windows, which is the
|
||||
worst place for a bug to live: no traceback survived, because the window it was printed
|
||||
in was destroyed by the same exit that printed it.
|
||||
|
||||
``ctypes.windll.kernel32`` is a cache shared by the whole process. MeshTerm's emoji-width
|
||||
probe declared ``GetConsoleScreenBufferInfo`` as taking a pointer to its own copy of the
|
||||
screen-buffer struct; prompt_toolkit then called that same function object with a pointer
|
||||
to *its* copy — same layout, different class — and ctypes refused the call while the app
|
||||
was building its screen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from meshterm.core import win32dll
|
||||
|
||||
pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows libraries")
|
||||
|
||||
|
||||
def test_each_handle_is_its_own() -> None:
|
||||
"""Two callers get two objects, so neither can see the other's declarations."""
|
||||
assert win32dll.kernel32() is not win32dll.kernel32()
|
||||
assert win32dll.user32() is not win32dll.user32()
|
||||
|
||||
|
||||
def test_the_shared_windll_is_the_thing_being_avoided() -> None:
|
||||
"""The premise: ``ctypes.windll`` hands the same function object to every caller.
|
||||
|
||||
If this ever stops being true the module is unnecessary — but until then it is the
|
||||
reason it exists, so it is worth asserting rather than assuming.
|
||||
"""
|
||||
first = ctypes.windll.kernel32.GetConsoleScreenBufferInfo
|
||||
second = ctypes.windll.kernel32.GetConsoleScreenBufferInfo
|
||||
assert first is second
|
||||
|
||||
|
||||
def test_declaring_a_signature_does_not_reach_the_shared_one() -> None:
|
||||
"""The regression itself: our declarations must not be visible to anyone else.
|
||||
|
||||
Before this module existed, the assignment below rewrote the signature that
|
||||
prompt_toolkit's console output later called through, and the application died on
|
||||
startup with ``expected LP__CSBI instance instead of pointer to
|
||||
CONSOLE_SCREEN_BUFFER_INFO``.
|
||||
"""
|
||||
|
||||
class _MineAlone(ctypes.Structure):
|
||||
_fields_ = [("whatever", ctypes.c_int)]
|
||||
|
||||
shared = ctypes.windll.kernel32.GetConsoleScreenBufferInfo
|
||||
before = shared.argtypes
|
||||
|
||||
private = win32dll.kernel32()
|
||||
private.GetConsoleScreenBufferInfo.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(_MineAlone),
|
||||
]
|
||||
|
||||
assert ctypes.windll.kernel32.GetConsoleScreenBufferInfo.argtypes == before
|
||||
assert private.GetConsoleScreenBufferInfo is not shared
|
||||
|
||||
|
||||
def test_the_probe_leaves_the_shared_signature_alone() -> None:
|
||||
"""End to end: running the emoji probe must not disturb the process-wide function.
|
||||
|
||||
The narrow assertion above can pass while the real call site still reaches for
|
||||
``ctypes.windll``, so this exercises the code that actually crashed.
|
||||
"""
|
||||
from meshterm.ui.tui import emoji_width
|
||||
|
||||
shared = ctypes.windll.kernel32.GetConsoleScreenBufferInfo
|
||||
before = shared.argtypes
|
||||
# No console under pytest, so this returns None rather than measuring. That is fine:
|
||||
# the declarations happen before the first call either way, which is what matters.
|
||||
emoji_width._win_probe_width("x") # noqa: SLF001 - the point is this private path
|
||||
assert ctypes.windll.kernel32.GetConsoleScreenBufferInfo.argtypes == before
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for reopening the session in Windows Terminal.
|
||||
|
||||
Nothing here starts a terminal. What is pinned is the command that *would* be run — which
|
||||
is the part that can be silently wrong, because a mistake in it produces a window that
|
||||
opens, fails, and closes faster than anyone can read it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from meshterm.core import winterminal
|
||||
|
||||
|
||||
def test_it_is_only_ever_windows(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Every other platform's terminal already draws the whole app; there is nothing to fix."""
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
assert winterminal.available() is None
|
||||
assert winterminal.reopen() is False
|
||||
|
||||
|
||||
def test_a_reopened_session_never_offers_to_reopen_again(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The loop guard. Windows Terminal would not qualify anyway, and this is the backstop.
|
||||
|
||||
A relaunch that could chain is not a cosmetic bug: each one spawns a window and exits,
|
||||
so the reader would watch windows open forever.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.setenv(winterminal.REOPENED_ENV, "1")
|
||||
assert winterminal.available() is None
|
||||
assert winterminal.reopen() is False
|
||||
|
||||
|
||||
def test_a_frozen_build_relaunches_itself(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The installer's own executable takes the arguments exactly as they were given."""
|
||||
monkeypatch.setattr(sys, "frozen", True, raising=False)
|
||||
monkeypatch.setattr(sys, "executable", r"C:\Downloads\meshterm.exe")
|
||||
monkeypatch.setattr(sys, "argv", ["meshterm", "--mock", "--profile", "home"])
|
||||
assert winterminal.own_command() == [
|
||||
r"C:\Downloads\meshterm.exe",
|
||||
"--mock",
|
||||
"--profile",
|
||||
"home",
|
||||
]
|
||||
|
||||
|
||||
def test_a_source_install_relaunches_through_its_interpreter(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``-m meshterm``, not the console script.
|
||||
|
||||
The script's path is a wrapper whose location varies by install method (venv, pipx,
|
||||
user site), while the interpreter running us is a fact we already hold. Using the
|
||||
interpreter also guarantees the new window gets the same environment as this one.
|
||||
"""
|
||||
monkeypatch.delattr(sys, "frozen", raising=False)
|
||||
monkeypatch.setattr(sys, "executable", r"C:\venv\Scripts\python.exe")
|
||||
monkeypatch.setattr(sys, "argv", ["meshterm", "--mock"])
|
||||
assert winterminal.own_command() == [r"C:\venv\Scripts\python.exe", "-m", "meshterm", "--mock"]
|
||||
|
||||
|
||||
def test_the_program_name_is_never_passed_on_as_an_argument(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``argv[0]`` is how this process was named, not something to hand to the next one.
|
||||
|
||||
Passing it through would reach MeshTerm as a stray positional argument and be rejected
|
||||
by Typer — in a brand new window that then closes.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "frozen", True, raising=False)
|
||||
monkeypatch.setattr(sys, "executable", r"C:\meshterm.exe")
|
||||
monkeypatch.setattr(sys, "argv", [r"C:\some\other\name.exe"])
|
||||
assert winterminal.own_command() == [r"C:\meshterm.exe"]
|
||||
|
||||
|
||||
def test_the_bundles_own_bookkeeping_never_reaches_the_new_process(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The bug the frozen build found, and the only one the source build could not.
|
||||
|
||||
A PyInstaller one-file executable unpacks itself and re-runs itself, the two halves
|
||||
talking through ``_PYI*`` variables. Passing those on made the reopened MeshTerm think
|
||||
it was the second half of a launch it never made; it checked its parent, found Windows
|
||||
Terminal, and killed itself with a bootloader error in a window that had just opened.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "frozen", True, raising=False)
|
||||
monkeypatch.setenv("_PYI_ARCHIVE_FILE", r"C:\Downloads\meshterm.exe")
|
||||
monkeypatch.setenv("_PYI_PARENT_PROCESS_LEVEL", "0")
|
||||
monkeypatch.setenv("_MEIPASS2", r"C:\Temp\_MEI123")
|
||||
monkeypatch.setenv("MESHTERM_HOME", r"C:\mine")
|
||||
|
||||
environment = winterminal.child_environment()
|
||||
|
||||
assert not [key for key in environment if key.startswith("_PYI")]
|
||||
assert "_MEIPASS2" not in environment
|
||||
assert environment["MESHTERM_HOME"] == r"C:\mine" # the reader's own settings survive
|
||||
assert environment[winterminal.REOPENED_ENV] == "1"
|
||||
|
||||
|
||||
def test_a_source_run_keeps_the_environment_it_was_given(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Nothing is stripped when there is no bundle, so a dev run is passed through whole.
|
||||
|
||||
``LD_LIBRARY_PATH`` is the one to watch: unfrozen it is the reader's, and dropping it
|
||||
would change how the new process loads its libraries for no reason at all.
|
||||
"""
|
||||
monkeypatch.delattr(sys, "frozen", raising=False)
|
||||
monkeypatch.setenv("LD_LIBRARY_PATH", "/opt/mine/lib")
|
||||
assert winterminal.child_environment()["LD_LIBRARY_PATH"] == "/opt/mine/lib"
|
||||
|
||||
|
||||
def test_the_loader_path_the_bootloader_replaced_is_put_back(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The bootloader points the loader at its own unpacked copy and parks the original.
|
||||
|
||||
The new process unpacks a bundle of its own, so it wants the value the reader started
|
||||
with — and where there was none, none.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "frozen", True, raising=False)
|
||||
monkeypatch.setenv("LD_LIBRARY_PATH", "/tmp/_MEI999")
|
||||
monkeypatch.setenv("LD_LIBRARY_PATH_ORIG", "/opt/mine/lib")
|
||||
monkeypatch.setenv("DYLD_LIBRARY_PATH", "/tmp/_MEI999")
|
||||
monkeypatch.delenv("DYLD_LIBRARY_PATH_ORIG", raising=False)
|
||||
|
||||
environment = winterminal.child_environment()
|
||||
|
||||
assert environment["LD_LIBRARY_PATH"] == "/opt/mine/lib"
|
||||
assert "LD_LIBRARY_PATH_ORIG" not in environment
|
||||
assert "DYLD_LIBRARY_PATH" not in environment
|
||||
Reference in new issue
Block a user