Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,94 @@ jobs:
name: BeanNetworkTester-windows
path: dist/BeanNetworkTester

# The Linux .tar.gz, built the way release.yml builds it: the same spec, the
# same pins, on the OLDEST Ubuntu the program supports (24.04, owner decision
# 2026-10-28), because the libraries in the bundle come from the system it is
# built on and start on that release and newer ones. A bundle built there was
# measured working on Ubuntu 26.04, Fedora 44 and Arch. Not in the branch
# ruleset, like the Linux capture job.
build-linux:
name: build the Linux package
runs-on: ubuntu-24.04
timeout-minutes: 25
needs: tests
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The interpreter release.yml freezes - see the Windows build above.
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.15"
check-latest: true
cache: pip
# Tk's own library (setup-python's tkinter links the system's), and the
# display, window list and desktop-entry checker the smoke uses.
- name: Tk, Xvfb and the X tools
run: |
missing=""
for package in libtk8.6 xvfb x11-utils desktop-file-utils; do
dpkg -s "$package" >/dev/null 2>&1 || missing="$missing $package"
done
if [ -n "$missing" ]; then
echo "installing:$missing"
# The mirror and the two-step install: see "GUI render check".
for source in /etc/apt/apt-mirrors.txt /etc/apt/sources.list \
/etc/apt/sources.list.d/*.sources \
/etc/apt/sources.list.d/*.list; do
if [ -f "$source" ]; then
sudo sed -i 's|azure.archive.ubuntu.com|archive.ubuntu.com|g' "$source"
fi
done
if ! sudo timeout 180 apt-get install -y --no-install-recommends $missing; then
sudo timeout 240 apt-get update \
-o Acquire::Retries=2 \
-o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15
sudo timeout 240 apt-get install -y --no-install-recommends $missing
fi
fi
- name: Install dependencies
run: pip install --require-hashes -r requirements.txt -r requirements-build.txt
- name: Build
run: pyinstaller --noconfirm BeanNetworkTester.spec
# The font stack from the system, Tk, Xft and libebpfdivert in the bundle -
# tools/linux_bundle.py says what each costs when it is wrong.
- name: The libraries the bundle carries
run: |
python tools/linux_bundle.py check dist/BeanNetworkTester
find dist/BeanNetworkTester -name '*.so*' -printf '%f\n' | sort
- name: The package as a person runs it (CLI, repro line, user files, window)
run: |
timeout 300 xvfb-run -a --server-args="-screen 0 1280x800x24" \
python -u tools/ci_linux_package_smoke.py dist/BeanNetworkTester/BeanNetworkTester
# A real capture by the frozen program, bounded as the Linux capture job's
# is: a namespace of its own, --dst-ip, --dst-port and --duration.
- name: A real capture by the package (namespace of its own)
run: |
sudo "$(command -v python)" tools/ci_linux_capture_smoke.py --require \
--program dist/BeanNetworkTester/BeanNetworkTester
# The licence registry held to the Linux bundle, as the Windows build holds it
# to the zip (legal.LINUX_COMPONENTS; the reason is in that job).
- name: Scan the bundle and hold the licence registry to it
uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
path: dist/BeanNetworkTester
format: syft-json
output-file: bundle-scan.json
upload-artifact: false
upload-release-assets: false
- name: The registry must account for everything the scan found
run: |
python -c "import json; print(sorted({a['name'] for a in json.load(open('bundle-scan.json'))['artifacts']}))"
python tools/sbom.py --audit-bundle bundle-scan.json
# A tar.gz, not the folder: an artifact zip loses the executable bit.
- name: Pack
run: tar -czf BeanNetworkTester-linux-x64.tar.gz -C dist BeanNetworkTester
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: BeanNetworkTester-linux
path: BeanNetworkTester-linux-x64.tar.gz

# 🔴 The weekly run tells nobody it went red. This job is the telling, and it
# reverses the 2026-08-17 decision - see the note beside `schedule` at the top.
#
Expand All @@ -787,7 +875,7 @@ jobs:
# case a second time, in tests/test_cron_issue.py.
cron-issue:
name: open an issue when the weekly run is red
needs: [public-text, lint, types, semgrep, audit, mutations, tests, linux-capture, build]
needs: [public-text, lint, types, semgrep, audit, mutations, tests, linux-capture, build, build-linux]
# 🔴 `workflow_dispatch` is here so this path can be PROVEN. Until it was
# added, the only way to find out whether `gh issue create` works with these
# permissions was a red Monday - which is to say, the notice that exists
Expand Down
66 changes: 43 additions & 23 deletions BeanNetworkTester.spec
Original file line number Diff line number Diff line change
Expand Up @@ -34,42 +34,57 @@
# and "--license" can show them from any copy of the app. build.py additionally
# places human-readable copies NEXT TO the exe: PyInstaller 6 puts datas inside
# _internal/, and a licence nobody can find is a licence nobody reads.
#
# 6. One spec for both systems.
# The Linux .tar.gz is this same onedir bundle. On Linux there is no version
# resource or .ico (PyInstaller's win32 module does not even import there:
# "cannot import name 'win32api'", measured), and the bundle takes the font
# stack from the system instead of carrying it (tools/linux_bundle.py says why).
import os
import sys

from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs
from PyInstaller.utils.win32.versioninfo import (FixedFileInfo, StringFileInfo,
StringStruct, StringTable,
VarFileInfo, VarStruct,
VSVersionInfo)

WINDOWS = sys.platform == "win32"

sys.path.insert(0, os.path.abspath("."))
from beantester.appinfo import (APP_NAME, AUTHOR, COPYRIGHT, EXE_NAME,
VERSION_FILE, __version__)

# Off the search path again at once: the analysis below must not find tools/ there.
sys.path.insert(0, os.path.abspath("tools"))
import linux_bundle
sys.path.remove(os.path.abspath("tools"))

if __version__ == "0.0.0":
raise SystemExit("BeanNetworkTester.spec: VERSION.txt is missing or malformed "
"(expected x.y.z). Refusing to build an exe with no version.")

_v = tuple(int(part) for part in __version__.split(".")[:3]) + (0,)

version_info = VSVersionInfo(
ffi=FixedFileInfo(filevers=_v, prodvers=_v, mask=0x3F, flags=0x0, OS=0x40004,
fileType=0x1, subtype=0x0),
kids=[
StringFileInfo([StringTable("040904B0", [
StringStruct("CompanyName", AUTHOR),
StringStruct("FileDescription", f"{APP_NAME} - poor network conditions simulator"),
StringStruct("FileVersion", __version__),
StringStruct("InternalName", "BeanNetworkTester"),
StringStruct("LegalCopyright", COPYRIGHT),
StringStruct("OriginalFilename", EXE_NAME),
StringStruct("ProductName", APP_NAME),
StringStruct("ProductVersion", __version__),
])]),
VarFileInfo([VarStruct("Translation", [1033, 1200])]),
],
)
version_info = None
if WINDOWS:
from PyInstaller.utils.win32.versioninfo import (FixedFileInfo, StringFileInfo,
StringStruct, StringTable,
VarFileInfo, VarStruct,
VSVersionInfo)
version_info = VSVersionInfo(
ffi=FixedFileInfo(filevers=_v, prodvers=_v, mask=0x3F, flags=0x0, OS=0x40004,
fileType=0x1, subtype=0x0),
kids=[
StringFileInfo([StringTable("040904B0", [
StringStruct("CompanyName", AUTHOR),
StringStruct("FileDescription", f"{APP_NAME} - poor network conditions simulator"),
StringStruct("FileVersion", __version__),
StringStruct("InternalName", "BeanNetworkTester"),
StringStruct("LegalCopyright", COPYRIGHT),
StringStruct("OriginalFilename", EXE_NAME),
StringStruct("ProductName", APP_NAME),
StringStruct("ProductVersion", __version__),
])]),
VarFileInfo([VarStruct("Translation", [1033, 1200])]),
],
)

datas = [("lang", "lang"), (VERSION_FILE, "."), ("LICENSE", "."),
("THIRD-PARTY-NOTICES.md", "."), ("licenses", "licenses"),
Expand All @@ -95,7 +110,8 @@ a = Analysis(
# never touches `ssl` - and its only hashing is crashlog's sha1 fingerprint, which
# `hashlib` computes via the built-in `_sha1` module when `_hashlib` is absent
# (verified). Dropping them is ~7 MB off the release at no runtime cost.
excludes=["pytest", "ssl", "_ssl", "_hashlib"],
excludes=["pytest", "ssl", "_ssl", "_hashlib"]
+ ([] if WINDOWS else list(linux_bundle.NOT_NEEDED_MODULES)),
noarchive=False,
)

Expand Down Expand Up @@ -123,6 +139,10 @@ _TCL_CRUFT = ("_tcl_data/tzdata/", "_tcl_data/msgs/", "_tk_data/msgs/")
a.datas = [d for d in a.datas
if not any(part in d[0].replace("\\", "/") for part in _TCL_CRUFT)]

if not WINDOWS:
a.binaries = [b for b in a.binaries if not (linux_bundle.taken_from_system(b[0])
or linux_bundle.not_needed(b[0]))]

pyz = PYZ(a.pure)

exe = EXE(
Expand All @@ -131,7 +151,7 @@ exe = EXE(
[],
exclude_binaries=True, # onedir
name="BeanNetworkTester",
icon="bean.ico" if os.path.exists("bean.ico") else None,
icon="bean.ico" if WINDOWS and os.path.exists("bean.ico") else None,
console=True, # the CLI must have stdout/stderr and an exit code
uac_admin=False, # asInvoker - the app elevates itself when needed
version=version_info, # exe Properties -> Details (author, version)
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol
ssh, it opens without root at once. `BEAN_NO_ELEVATE=1` turns the question off. The files it
saves stay yours, as under sudo.

- **Linux: a window the desktop can tell apart.** The window now has a class of its own, not
the "Tk" every Tk program shares, so the desktop does not group it with other Tk windows.
When the window cannot open because the system lacks a library it needs, the line names
that library instead of saying tkinter is missing, and the command line still works.

- **Ping on the Tools tab, to see the session's latency and loss arrive.** Type an address or a
name and press **Start**: each ping is a row with its round trip and what came back, the last
60 are charted, and the totals say what was lost. The ping goes on while another tab is in
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,8 @@ connections arriving from outside, are still blocked in silence.
| `--simulate` | synthetic traffic instead of WinDivert (test with no Windows, no driver, no admin) |
| `--gui` | open the GUI. Valid **on its own only** - the GUI has its own controls, so combining it with settings flags is a usage error (exit 2) rather than a silent headless run |
| `--version` | print the version and exit |
| `--install-desktop` | the Linux package only: add the program to your desktop's applications menu, then exit. Run it **without** `sudo`, from the folder you keep the program in. After moving the folder, run it again |
| `--remove-desktop` | the Linux package only: remove that menu entry again, then exit |

**Output and diagnostics**

Expand Down Expand Up @@ -1566,6 +1568,7 @@ Every job in that workflow, and what a red one means:
| `tests` | the suite, the GUI smoke, the real-Tk render check and the CLI assertions, on Linux and Windows |
| `linux-capture` | the experimental Linux capture on a real kernel: the command line, as root, inside a network namespace of its own. The latency and the loss must reach the target and nothing else. Not required for a merge |
| `build` | the Windows executable, smoke-tested, with the driver check and the licence registry scan |
| `build-linux` | the Linux package on Ubuntu 24.04: the libraries it carries, the program run as a person runs it (command line, window, menu entry) under Xvfb, and a real capture by the packaged program in a namespace of its own. Not required for a merge |
| `cron-issue` | opens an issue when the weekly run goes red, deduplicated by which jobs failed. A cron is easy to miss and nothing else announces one. It also answers to a hand-fired run, which is the only way to exercise it without waiting for a red Monday. Never runs on a pull request, and never closes an issue by itself |
<!-- ci-jobs:end -->

Expand Down Expand Up @@ -1632,6 +1635,7 @@ beantester/ the implementation package
clilog.py CLI output: log on stderr ([bean]), data on stdout (text/NDJSON)
winenv.py Windows: admin, elevation (UAC), console detach, DPI
linuxenv.py Linux: the window asks for root through polkit (pkexec)
desktop.py Linux package: its entry in the desktop's applications menu
driver.py WinDivert driver lifecycle + --doctor / --cleanup-driver
fields.py FIELD REGISTRY - single source of truth: type, label, unit,
range, form section, profile scope, CLI flag
Expand Down
Loading
Loading