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
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,35 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol

### Fixed

- **The window opens wider, and the lists on the Tools tab show every column whole.** At 760
pixels the TCP health list lost its last column in every language, and words such as
"jeszcze bez pomiaru" (TCP health) and "Ustawienia proxy" (Adapters) were cut. The window
now opens 960 pixels wide at 100% and cannot be made narrower. Each column fits the longest
word it can show, and an empty list's note wraps inside it. A window size saved by an older
version grows to the new width and stays on its screen.

- **Linux, experimental: the texts say what Linux does.** The Sockets note says that Linux
shows the programs of other accounts only to root, and that a connection that is closing,
or a socket held from outside the system, as in WSL, may have no program at all. A socket
in TIME_WAIT shows PID 0, as on Windows. The process field, its "?" help and `--help` show
`chrome`, which finds the program on Windows and on Linux. TCP health on a kernel without
the socket list says so.

- **Linux, experimental: Copy CLI command gives a command Linux can run.** It is the command
the README starts the program with: `sudo .venv/bin/python -B bean_network_tester.py`,
without sudo for `--simulate`. Its values are in single quotes, so bash keeps a `!`, a `$`
or a backslash in them as it is.

- **Texts that were not true on every system.** The process field's tip no longer says it
requires psutil, which comes with the program and which Windows does not use for it. On a
system other than Windows and Linux, `--doctor` and the Tools tab say that capture and the
tools work on Windows and Linux.

- **The names of the tools on the Tools tab are whole.** On Linux in Polish, and in every
language but Chinese once the window was made narrow, each name lost its last letters, such
as "Zdrowie TCI" for "Zdrowie TCP". The tabs inside a page are now narrower than the page
tabs above them, so the seven tools fit in one row. The main window can no longer be made
narrower than it opens, on any screen and at any display scaling (760 pixels at 100%).
narrower than it opens, on any screen and at any display scaling.

- **The log no longer says a page opened when no browser started.** After a click on **Donate**
the log said the support page was opened even when the browser did not start. It now says the
Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,8 @@ program says so in the log when you apply it, and each of the two counters repor
**Target process** - narrow the effect to chosen apps: process name (e.g. `chrome.exe`), PID, a
comma-separated list, PID range, wildcard or regular expression - see
[Filter syntax](#filter-syntax-process--ip--port). The rest of the machine's traffic stays
untouched. Empty field = all traffic.
untouched. Empty field = all traffic. On Linux a process name has no `.exe` (`chrome`,
`firefox`), and a bare name such as `chrome` finds the program on both systems.

**Speed limit** - maximum throughput separately for download (inbound) and upload (outbound), in
KB/s. 0 = no limit. Ping is small packets, so a speed limit barely changes it - to test the limit
Expand Down Expand Up @@ -934,7 +935,10 @@ Designed so that after a bug you can recreate exactly the same conditions:
says which queue produced its numbers. The same three values go into the log at START.
- **Copy CLI command** - straight to the clipboard: `BeanNetworkTester.exe --seed ... --loss ...
--duration ...` (the command adapts to the build: from the repository you get
`python bean_network_tester.py ...`).
`python bean_network_tester.py ...`, and on Linux the way this README starts it,
`sudo .venv/bin/python -B bean_network_tester.py ...`, with no `sudo` and no `-B` for
`--simulate`. A virtual environment outside the checkout is named from `~`, and any other
interpreter as `python3`, so the command carries no path from your machine).
- **What the report and the command describe** - the last session, not the form: the settings
START gave it, updated by every "Apply changes", with its seed and its scenario
(`--scenario`, plus `--loop` if it looped). A change you typed but did not apply is left out,
Expand Down Expand Up @@ -1062,8 +1066,9 @@ connections arriving from outside, are still blocked in silence.

> In `cmd.exe`/PowerShell **quote the expression** if it contains a comma, `!`, `>`, `<`, `*` or
> `^` - otherwise the shell interprets it its own way (`cmd.exe` drops a bare `^`, so `re:^edge`
> would arrive as `re:edge`). The command that recreates the session
> (`Copy CLI command` and the `Reproduce:` line) quotes them for you.
> would arrive as `re:edge`). In bash use single quotes: inside double quotes bash still reads
> `$`, a backtick and `!` its own way. The command that recreates the session
> (`Copy CLI command` and the `Reproduce:` line) quotes them for you, for the shell of the system.

**Run and reporting**

Expand Down
60 changes: 56 additions & 4 deletions beantester/appinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
drift. ``tests/test_repo_conventions.py`` fails the build if a copy appears.
"""
import re
import sys

APP_NAME = "Bean Network Tester" # human-readable window / product name
AUTHOR = "DonislawDev" # shown in the GUI, the exe metadata and --version
Expand Down Expand Up @@ -50,14 +51,65 @@ def _read_version():
__version__ = _read_version()


def command_name():
"""How the user invokes this build: the exe when frozen, the script otherwise.
def command_name(simulate=False):
"""How the user invokes this build: the exe when frozen, the script otherwise -
on Linux the way its README starts it (``linux_command``).

Reproduction commands and ``--help`` must be copy-paste ready, and a frozen
build has no ``python bean_network_tester.py`` to offer.
build has no ``python bean_network_tester.py`` to offer. ``simulate``: the
command runs ``--simulate``, which on Linux needs no root.
"""
from .paths import is_frozen
return EXE_NAME if is_frozen() else f"python {LAUNCHER}"
if is_frozen():
return EXE_NAME
if sys.platform.startswith("linux"):
return linux_command(simulate)
return f"python {LAUNCHER}"


def linux_command(simulate=False, executable=None, checkout=None, home=None):
"""The script as Linux runs it from this checkout, the README's way:
``sudo .venv/bin/python -B bean_network_tester.py``.

MEASURED 2026-10-27 on Ubuntu 26.04: there is no ``python`` there, only
``python3``, for a user or under sudo, so the command the window copied ended
in "command not found"; a capture needs root, so without ``sudo`` it ended with
exit code 7; and pydivert lives in the virtual environment, which the system's
``python3`` is not. So ``sudo`` and ``-B`` (root leaves no cache folders in the
checkout) unless the command is a simulation, and the interpreter running now,
named with no path from this machine - a report carrying the command is shared:
relative to the checkout when it is inside it, from ``~`` when it is in the
person's home, else ``python3``.
"""
from . import paths
executable = sys.executable if executable is None else executable
checkout = paths.PROJECT_ROOT if checkout is None else checkout
home = _persons_home() if home is None else home
interpreter = (_inside(executable, checkout, "")
or (home and _inside(executable, home, "~/")) or "python3")
return " ".join(([] if simulate else ["sudo"]) + [interpreter]
+ ([] if simulate else ["-B"]) + [LAUNCHER])


def _inside(path, base, prefix):
"""``path`` as ``prefix`` + its place under ``base``, or None when it is not under it."""
import posixpath
import shlex
if not path or not base:
return None
place = posixpath.relpath(posixpath.abspath(path), posixpath.abspath(base))
if place == posixpath.pardir or place.startswith(posixpath.pardir + "/"):
return None
return prefix + shlex.quote(place)


def _persons_home():
"""The home of the person running the program: under sudo theirs, not root's."""
import os

from . import paths
person = paths.invoking_user()
return paths.home_of(person[0]) if person else os.path.expanduser("~")


def version_line():
Expand Down
5 changes: 3 additions & 2 deletions beantester/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@
loads a Windows DLL is either named here or listed there with the reason it gates
itself.

``reason`` is an i18n key: Windows only for now. A tool ported to Linux may still be
``reason`` is an i18n key: the shared words, which name the systems the tools work
on (Windows and Linux). A tool ported to Linux may still be
missing on one system or another, and then its module says why in its own words
(``why_not()``, asked when it is there): Ping, the first one, needs the ping socket
for the user's group or sudo (``icmp.why_not``). Reset on Linux needs a kernel built
with ``CONFIG_INET_DIAG_DESTROY`` (WSL's is not); it has no words of its own, because
its only reader is a row menu, which greys the entry out - the README says why. TCP
health on Linux needs a kernel that lists TCP sockets through sock_diag (every one
measured does); without it the shared words are said.
measured does); without it the module says so (``tcpestats.why_not``).
"""
from types import ModuleType
from typing import NamedTuple, Optional
Expand Down
2 changes: 1 addition & 1 deletion beantester/capture_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class WinDivertBackend:
# Off Windows (a platform with no capture driver) there is no WinDivert service.
CLEANUP_LINE = "Not Windows - there is no WinDivert driver to clean up."
CLEANUP_BLOCKER = "tools.diagnostics.clean_windows_only"
# ...and no rows of its own to explain: its --doctor rows say capture needs Windows.
# ...and no rows of its own to explain: its --doctor rows say capture needs Windows or Linux.
DIAGNOSTICS_HELP: Tuple[str, ...] = ()

@staticmethod
Expand Down
6 changes: 3 additions & 3 deletions beantester/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def _read_scenario(path, log):
%(prog)s --simulate --loss 20 --duration 10
try it out. No driver, no real traffic, nothing to break.

%(prog)s --target chrome.exe --latency 200 --duration 30
%(prog)s --target chrome --latency 200 --duration 30
impair one application for half a minute, and nothing else.

%(prog)s --dst-ip 10.0.0.5 --dst-port 443 --loss 5 --duration 60
Expand Down Expand Up @@ -142,7 +142,7 @@ def _add_scope_arguments(p):
p.add_argument("--target",
help="target processes: name/PID, comma-separated list, range, "
"wildcard, re: pattern, ! to exclude "
"(e.g. 'chrome.exe,!chromedriver' or 're:^fire')")
"(e.g. 'chrome,!chromedriver' or 're:^fire')")
p.add_argument("--dst-ip",
help="affect only traffic to/from these remote IPs, IPv4 and IPv6: "
"address, list, range a-b, CIDR, wildcard, comparison, re: "
Expand Down Expand Up @@ -1364,7 +1364,7 @@ def _window_did_not_open(exc):
in place of stderr, so a double-clicked exe just vanished with exit code 1.
"""
if isinstance(exc, ImportError) and exc.name in _TK_MODULES:
text = f"No tkinter. Use CLI mode, e.g.: {command_name()} --simulate --loss 5"
text = f"No tkinter. Use CLI mode, e.g.: {command_name(simulate=True)} --simulate --loss 5"
else:
text = f"The window could not open: {type(exc).__name__}: {exc}"
if crashlog.record(exc, source="gui-start") is not None:
Expand Down
2 changes: 1 addition & 1 deletion beantester/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -771,7 +771,7 @@ def doctor():
("platform", "ok" if is_windows() else "warn",
f"{platform.system()} {platform.release()}"
+ ("" if is_windows() else
note if note is not None else " - capture needs Windows; use --simulate")),
note if note is not None else " - capture needs Windows or Linux; use --simulate")),
("frozen", "ok", "yes" if getattr(sys, "frozen", False) else "no"),
_clock_check()]

Expand Down
15 changes: 6 additions & 9 deletions beantester/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@
from .profiles import ProfileStore
from .rates import SessionPeaks
from . import scope
from .scaling import (geometry_fits, init_scaling, initial_geometry,
max_window_size, min_window_size, scaled)
from .scaling import (init_scaling, max_window_size, min_window_size, restored_geometry,
scaled)
from .scrollable import WheelDispatcher
from .theme import (BG, FIELD, FG, MONO_FONT, MUT, apply_dark_titlebar,
disable_maximize, init_style, popdown_height,
Expand Down Expand Up @@ -266,15 +266,12 @@ def _restore_geometry(self):
except Exception:
screen_w, screen_h = 1366, 768
maximum = max_window_size(screen_w, screen_h)
saved = str(self.ui.get("geometry", "") or "")
if saved and geometry_fits(saved, screen_w, screen_h, winenv.monitor_work_area):
geometry = saved
else:
w, h, x, y = initial_geometry(screen_w, screen_h)
geometry = f"{w}x{h}+{x}+{y}"
minimum = min_window_size(screen_w=screen_w, screen_h=screen_h)
geometry = restored_geometry(str(self.ui.get("geometry", "") or ""), minimum,
screen_w, screen_h, winenv.monitor_work_area)
try:
root.geometry(geometry)
root.minsize(*min_window_size(screen_w=screen_w, screen_h=screen_h))
root.minsize(*minimum)
# a capped size AND no maximise button: the layout has an upper bound
# at which it still looks like a layout, so do not offer more
root.maxsize(*maximum)
Expand Down
96 changes: 86 additions & 10 deletions beantester/gui/scaling.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@
# measured, and it showed: at the old 620 px minimum the Tools sub-tabs were cut in
# every language but Chinese, Windows included (2026-10-26). Width is what cuts - a
# row of tabs or buttons cannot wrap - while a shorter window scrolls, so the
# minimum HEIGHT stays below the first-run one.
FIRST_RUN = (760, 920)
# minimum HEIGHT stays below the first-run one. 960 since 2026-10-27, 760 before: at
# 760 the list of TCP health had a 691 px table for six columns that wanted 699 to
# 716 (real Tk, Windows and Linux), so its last column was cut in every language, and
# with whole words and the longest IPv4 address and port they want 858 - the table
# is 891 at 960 (rendered, Linux's widest font). 960 also fits a 1366 px screen at
# 125 %; at 150 % that screen caps it, as it always capped a wider window.
FIRST_RUN = (960, 920)
MIN_H = 560

_scale = 1.0
Expand Down Expand Up @@ -95,6 +100,18 @@ def column_width(header, min_chars=0, padding=22):
return max(text_width(header, padding), scaled(min_chars * 7 + padding))


# A treeview cell shows its text whole from about 4 px wider than the text at 100 %
# (6 at 150 %) - MEASURED 2026-10-27 on real Tk, the clam theme: the text starts that
# far in, and nothing is kept on its right. 8 leaves room for a glyph whose ink
# reaches past its advance.
CELL_PAD = 8


def cell_width(text, font=None):
"""Width a table column needs to show ``text`` whole in one of its cells."""
return text_width(text, CELL_PAD, font)


def initial_geometry(screen_w, screen_h, want=FIRST_RUN, scale=None):
"""Startup window geometry, clamped to the screen and centred.

Expand Down Expand Up @@ -183,29 +200,88 @@ def geometry_fits(geometry, screen_w, screen_h, bounds_at=None):
size lost, not a window lost, and it buys the case that matters: a window on a
monitor LARGER than the primary one, which the old check rejected outright.
"""
parsed = _parse_geometry(geometry)
if parsed is None:
return False
w, h, x, y = parsed
left, top, right, bottom = _monitor_of(x, y, screen_w, screen_h, bounds_at)
if w < 320 or h < 320 or w > right - left or h > bottom - top:
return False
# allow a little off-screen slack, but the title bar must stay reachable
return left - 20 <= x <= right - 100 and top <= y <= bottom - 60


def restored_geometry(saved, minimum, screen_w, screen_h, bounds_at=None):
"""The ``WxH+X+Y`` the main window opens with: the one saved, grown to
``minimum`` (``grown_to``) while it still describes a reachable window
(``geometry_fits``), else the first-run size, centred.

The saved geometry is judged BEFORE it grows as well. Growing moves the corner
onto the monitor nearest to it, so a narrow window saved on a monitor that has
since been unplugged came back pulled onto the edge of the one left, instead of
in the centre of the screen."""
if geometry_fits(saved, screen_w, screen_h, bounds_at):
grown = grown_to(saved, *minimum, screen_w, screen_h, bounds_at)
if geometry_fits(grown, screen_w, screen_h, bounds_at):
return grown
w, h, x, y = initial_geometry(screen_w, screen_h)
return f"{w}x{h}+{x}+{y}"


def grown_to(geometry, min_w, min_h, screen_w, screen_h, bounds_at=None):
"""A saved ``WxH+X+Y`` no smaller than the window's minimum, still on its monitor.

Tk grows a window restored below its ``minsize`` by itself (measured 2026-10-27
on Windows and Linux: a saved 760x920 under a 960 minimum opens 960 wide), but it
grows it to the RIGHT, so a window an older version saved against the right edge
of its monitor opened with 200 px past that edge. The size is grown here instead,
and the corner moved left (or up) by as much as the growth would push the window
past its monitor, never past the monitor's left (top) edge. A window that already
has the minimum, and anything that is not a geometry, come back unchanged:
``geometry_fits`` judges them as it always did.
"""
parsed = _parse_geometry(geometry)
if parsed is None:
return geometry
w, h, x, y = parsed
if w >= min_w and h >= min_h:
return geometry
left, top, right, bottom = _monitor_of(x, y, screen_w, screen_h, bounds_at)
w, h = max(w, int(min_w)), max(h, int(min_h))
if x + w > right:
x = max(left, right - w)
if y + h > bottom:
y = max(top, bottom - h)
return f"{w}x{h}+{x}+{y}"


def _parse_geometry(geometry):
"""``(w, h, x, y)`` of a ``WxH+X+Y`` string, or ``None`` for anything else."""
try:
size, _, rest = str(geometry).partition("+")
w_s, _, h_s = size.partition("x")
w, h = int(w_s), int(h_s)
except (TypeError, ValueError):
return False
return None
parts = rest.split("+")
try:
# Tk writes a negative coordinate as "+-1882", so splitting on "+" leaves
# the sign on the number - a monitor left of the primary one survives the
# round trip through ui.json.
x, y = (int(parts[0]), int(parts[1])) if len(parts) >= 2 else (0, 0)
except ValueError:
return False
left, top, right, bottom = 0, 0, int(screen_w), int(screen_h)
return None
return w, h, x, y


def _monitor_of(x, y, screen_w, screen_h, bounds_at):
"""The work area a window's corner at ``(x, y)`` is judged by: the monitor
``bounds_at`` names for it, else the primary screen."""
if bounds_at is not None:
area = bounds_at(x, y)
if area is not None:
left, top, right, bottom = (int(edge) for edge in area)
if w < 320 or h < 320 or w > right - left or h > bottom - top:
return False
# allow a little off-screen slack, but the title bar must stay reachable
return left - 20 <= x <= right - 100 and top <= y <= bottom - 60
return tuple(int(edge) for edge in area)
return 0, 0, int(screen_w), int(screen_h)


def centred_in(bounds, width, height):
Expand Down
Loading
Loading