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/
|
||||
|
||||
+118
-2
@@ -7,8 +7,119 @@ 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.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 +142,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 +154,8 @@ 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.3...HEAD
|
||||
[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
|
||||
@@ -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,84 @@ 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.3-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.3-*
|
||||
./meshterm-0.2.3-*
|
||||
```
|
||||
|
||||
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.3-*` 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.3-* # macOS, Linux
|
||||
> ```
|
||||
> ```powershell
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.3-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 +325,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
|
||||
|
||||
+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.3"
|
||||
__author__ = "Jean-Pierre Martineau"
|
||||
|
||||
#: The year MeshTerm was first published; the copyright span starts here.
|
||||
|
||||
+148
-16
@@ -10,9 +10,11 @@ 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.admin_store import AdminStore
|
||||
@@ -20,9 +22,10 @@ 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 .tools import all_tools
|
||||
@@ -68,7 +71,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.
|
||||
@@ -135,9 +144,19 @@ def main_callback(
|
||||
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 +179,60 @@ def main_callback(
|
||||
if ctx.invoked_subcommand is None:
|
||||
from .ui.menu import run_menu
|
||||
|
||||
asyncio.run(_drive(run_menu(app_ctx), app_ctx))
|
||||
# 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 _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")
|
||||
@@ -212,20 +284,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 +356,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 = ctypes.windll.kernel32.GetConsoleProcessList(buffer, 4) # type: ignore[attr-defined]
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -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,16 @@ _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. Named rather than numeric, because those names are what every
|
||||
#: other tool calls these and the reader is often being talked through it by someone else.
|
||||
_LOG_LEVEL_CHOICES: dict[str, str] = {
|
||||
"ERROR": "Errors only",
|
||||
"WARNING": "Warnings and errors",
|
||||
"INFO": "Also what the app is doing",
|
||||
"DEBUG": "Everything, for chasing a bug",
|
||||
}
|
||||
|
||||
|
||||
PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
# --- Sending -----------------------------------------------------------------
|
||||
PrefSpec(
|
||||
@@ -282,6 +294,20 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
default="auto",
|
||||
choices=_WIDTH_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 +628,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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
Reference in new issue
Block a user