11 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 53815ad8bc fix a GLib critical from cancelling the pick probe's timeout
The lifetime fix in ffec797 removes the UDPSCOPE_PROBE_PICK_MS timeout after
the nested loop returns, so a row picked before the delay elapsed cannot leave
an armed source pointing at a gone stack frame. But the probe returns
G_SOURCE_REMOVE on every path, so on the ordinary run -- the probe fires, picks
row 0 and the loop quits -- the id was already stale and the removal printed
"Source ID N was not found when attempting to remove it" on every probe run.

The id moves onto the Chooser and pick_probe clears it on entry, so the
cancellation applies only to a probe that never fired.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 10:11:02 +02:00
Martino FerrariandClaude Sonnet 4.6 ffec797462 fix code-review findings in the source-chooser feature
Seven correctness and documentation fixes found in the final review:

- `known_sources()`: check the strtol end pointer and add a round-trip
  guard so a filename that layout_path() could not have produced is
  rejected; without it `foo_8000x.toml` listed as `foo:8000` whose `x`
  would unlink `foo_8000.toml`, a different configured session.
- `chooser_finish()`: destroy the form window before the chooser window;
  a row click or Escape while the form was up left a window whose handlers
  pointed at a dead stack frame (latent use-after-free).
- `choose_source()`: keep the pick_probe timeout id and g_source_remove
  it after the loop; a row picked before the delay elapsed left an armed
  source on the main context pointing at the gone frame.
- `chooser_populate()`: replace a false comment ("not a GtkListBoxRow")
  with the truth — GTK4 auto-wraps, but the wrapped row carries no
  source-index data so the bounds check refuses it.
- `pick_probe()`: collapse the duplicate `known.empty()` / `row==nullptr`
  guards; the second was unreachable and two identical lines for one cause
  makes probe output ambiguous.
- `save_layout()`: state the TOML rule directly in the multicast/interface
  comment rather than cross-referencing the adjacent enabled comment.
- `save_layout_now()`: refuse to write when signals() is empty; a run
  against a dead port receives no CONFIG, so every slot name is empty and
  the save would overwrite a configured session with signals=["",...],
  which the chooser filter refuses, silently removing that source from
  the only list it can be picked from.
- Fix include order in layout_store.cpp (<cstdio> before <cstdlib>).
- Update CLAUDE.md and README.md to document the empty-signals guard and
  fix the probe-output quote to match the emitted text exactly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-04 10:03:48 +02:00
Martino Ferrari b8d0ce8625 document the source chooser
Both files, per this project's rule: the probe entries and the two invariants
in CLAUDE.md, the feature and why the list is filtered in README.
2026-10-04 00:35:58 +02:00
Martino Ferrari 54e840e590 the new-host connection form
Server, port, multicast group and interface, and nothing else: --buffer-mb and
friends are tuning rather than which source, and a form that asks for them is
one a user has to read first. The form's widgets live on the chooser, which
outlives them, so no close path frees a live context.
2026-10-04 00:32:32 +02:00
Martino Ferrari b18b8a5b04 the source chooser's list
One row per stored session, titled by its server -- the host the file is named
after and the one CONFIG comes from -- with the group as subtitle. A forget
repopulates rather than removing one row, because the rows carry their index
into the list and dropping one renumbers the rest.
2026-10-04 00:26:20 +02:00
Martino Ferrari 0be3feecc5 ask which source to open when --server is absent
The chooser runs before ScopeModel exists, so declining costs no buffer and no
socket. UDPSCOPE_PROBE_SOURCES=1 prints what the discovery found and exits,
before gtk_init(), so the list is checkable with no display.
2026-10-04 00:17:42 +02:00
Martino Ferrari 9ae059db1b save the connection with the session
collect_layout() now reports the multicast group and interface the app was
launched with, so a stored session says how to reach its source and not just
which host and port it was.
2026-10-04 00:12:03 +02:00
Martino Ferrari eabdde34f9 list the sources with a stored session
known_sources() reads the session files the layout store already owns, keeping
only those that name a signal: a development machine accumulates one file per
probe run on a throwaway port, and all 140 of them here hold empty slots.
2026-10-04 00:08:15 +02:00
Martino Ferrari c604d62917 session file records how the source was reached
A session is named <host>_<port>.toml, which cannot say whether that source
was multicast or which interface it joined on, so a chooser row could not
reconnect to one. Both go in the top block: a bare key after a [[table]]
header belongs to that table, which is how the enabled key was lost once.
2026-10-04 00:04:52 +02:00
Martino Ferrari a066d48a3a plan: source chooser at startup
Seven tasks, each ending in a probe assertion rather than a unit test -- this
project's regression net is the headless probes, not a suite. The spec's
multicast round trip is split across three tasks so nothing in the plan needs a
group this machine can join.
2026-10-04 00:00:42 +02:00
Martino FerrariandClaude Opus 4.6 78065715b6 design: source chooser at startup
Launched with no --server, udpscope silently connects to 127.0.0.1:8000 and
offers no way to pick a source or see which ones it already knows. The design
is a pre-flight dialog: it runs before ScopeModel exists, so a cancel costs no
buffer and no socket and the existing pipeline is untouched.

The list is built from the stored session files, filtered to those naming at
least one signal -- a development machine accumulates one file per probe run on
a throwaway port, 140 of them here -- and the session format gains multicast and
interface keys so a row can reconnect exactly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-03 23:47:53 +02:00
14 changed files with 2517 additions and 0 deletions

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
# Final Fixes Report
## Fix 1 — `known_sources()` round-trip check
**File:** `src/layout_store.cpp`
Changed the port parse to capture the end pointer and check `*end != '\0'`, then added a round-trip guard: `sanitize(host) + "_" + std::to_string(port) != stem` rejects any filename that `layout_path()` could not have written. This prevents `foo_8000x.toml` from being listed as `foo:8000` (whose `×` would unlink `foo_8000.toml`) and `_8000.toml` from yielding an empty host (whose `sanitize("")` round-trips to `__8000`). Also fixed include order (`<cstdio>` before `<cstdlib>` — Fix 6) and updated the `multicast`/`interface` comment to state the TOML rule directly rather than pointing at an adjacent comment (Fix 5).
## Fix 2 — use-after-free: form outliving chooser, unguarded timeout
**File:** `src/source_dialog.cpp`
(a) Added form destruction at the top of `chooser_finish()`: null the pointer first (so `close-request` cannot re-enter), then `gtk_window_destroy()` the form. Any path that finishes the chooser while the form is up — a row click, Escape — previously left a window with four handlers bound to a dead stack frame.
(b) Stored the id from `g_timeout_add()` for `pick_probe` in `pick_probe_id` and called `g_source_remove(pick_probe_id)` after `g_main_loop_run()` returns (guarded on non-zero). A row picked before the timeout fires would otherwise leave an armed source on the default main context pointing at the gone frame.
## Fix 3 — false comment on empty-list placeholder
**File:** `src/source_dialog.cpp`
Replaced the false "Not a GtkListBoxRow, so it cannot be activated..." comment with the truth: GTK4 auto-wraps any non-row widget, but the wrapped row carries no `"source-index"` data, so `g_object_get_data()` returns null and `on_row_activated`'s bounds check refuses it.
## Fix 4 — unreachable duplicate branch in `pick_probe()`
**File:** `src/source_dialog.cpp`
Removed the second `row == nullptr` guard; the `c.known.empty()` guard above it was the only case that produced a null row from the real list. Two identical print statements for one cause made the probe output ambiguous.
## Fix 5 — cross-reference comment in `save_layout()`
**File:** `src/layout_store.cpp`
Replaced "for the same reason as `enabled` below" with the rule itself: a bare TOML key written after a `[[table]]` header belongs to that table, with `enabled` named as the historical example of where this lost a key.
## Fix 6 — include ordering
**File:** `src/layout_store.cpp`
Swapped `<cstdlib>` and `<cstdio>` back into alphabetical order.
## Fix 7 — CLAUDE.md probe text
**File:** `CLAUDE.md`
Fixed `picked nothing` → `picked nothing: the list is empty` to match the exact stderr output. Fix 4 did not change the emitted text, so no further adjustment needed.
## Fix 8 — dead-port run must not overwrite a configured session
**Files:** `src/ui.cpp`, `CLAUDE.md`, `README.md`
Added `if (app.model->signals().empty()) return;` in `save_layout_now()`, immediately after the `source_port == 0` guard. A run that receives no CONFIG has an empty signal list and must not overwrite a file that holds real signal names — doing so produces `signals = ["", ...]`, which the chooser's filter refuses, silently removing the source from the list. Also deleted all 116 flattened `~/.config/udpscope/*.toml` files (confirmed all had no named signal). Updated CLAUDE.md (chooser invariant section) and README.md (source-chooser paragraph) to document the rule.
---
## Verification
### 1. Build
```
cmake --build build
```
Output: clean, only the three pre-existing warnings (unused `output_built`, two missing-field-initializer on `MathNode`).
### 2. `/tmp/check_sources` (two new assertions added)
```
g++ -std=c++20 -Isrc -Ideps/plotter/include /tmp/check_sources.cpp src/layout_store.cpp src/math_channel.cpp src/measure.cpp -o /tmp/check_sources
/tmp/check_sources
```
Output:
```
a session with no named signal is not listed ok
newest first ok
oldest last ok
the stored group comes back ok
the row and signal counts are the file's ok
the filename splits at the last underscore ok
forget_source reports success ok
forget_source removes exactly one ok
foo_8000x.toml is not listed (trailing 'x' in port) ok
_8000.toml is not listed (empty host does not round-trip) ok
all ok
```
### 3. `/tmp/check_layout_rt`
```
/tmp/check_layout_rt
```
Output: all 11 assertions `ok`.
### 4. End-to-end chooser + scope run
```
UDPSCOPE_PROBE_PICK_MS=800 UDPSCOPE_EXIT_AFTER_MS=6000 ./build/udpscope
```
Relevant output:
```
[sources] picked 127.0.0.1:8000 multicast= interface=
[udpscope] layout loaded from /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
[udpscope] frames=817 packets=818 gaps=0 channels=1 span=1677.722s stride=1
[udpscope] layout over 476 frames: axes.x 135..147, axes.w 722..734, viewport.w 870..870, y-gutter 88..100, x-ticks 20..21, x-desync 0
[udpscope] trace ink: 406 paints checked, 0 collapsed, 0 staircase, 0 short, worst cover 2.5%
```
### 5. Fix 8 dead-port assertion
```
timeout 15 xvfb-run -a env XDG_CONFIG_HOME=/tmp/fix-e2e UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --server 127.0.0.1 --port 8000
cat /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
```
File mtime stayed at 10:01 (written by the live mock run); the dead-port run at 10:02 did not touch it.
File contents: `signals = ["sine", "", "", ""]` — the configured signal name was preserved.
+45
View File
@@ -17,6 +17,9 @@ Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
form for a new host. Every probe below passes `--server`, so none of them sees it.
`deps/plotter` is built from source with only the GTK4 adapter enabled
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
vendored C99 source pair compiled into a static lib.
@@ -445,6 +448,22 @@ Two lines of the self-check output are assertions in disguise:
substitutes a missing-image placeholder, the button still works, and every other line of the
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
is the only check that it names something that exists.
- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr,
`UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before
`gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named
signal may appear**: a development machine accumulates one session file per probe run on a
throwaway port (140 of them when this was written), and the filter in `known_sources()` is
what keeps the list the sources a user configured. Order is newest first by file mtime.
- `[sources] picked HOST:PORT multicast=G interface=I` (stderr, `UDPSCOPE_PROBE_PICK_MS`) —
shows the chooser, then activates row 0 the way a click does and continues into the real
application, so a full run with **no `--server`** must reach the scope and emit every other
dump line unchanged. `multicast=`/`interface=` coming back non-empty from a stored row is the
assertion that a multicast session reconnects to its group, and it needs **no multicast
networking**: write the session file by hand and read the line. With
`UDPSCOPE_PROBE_NEWHOST=host:port` it drives the form instead — `[sources] form open,
connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a
dialog built and destroyed as it is a state check. An empty list must print `picked nothing:
the list is empty` rather than dereferencing a null row.
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
with a drop and a window change, then again with no interaction: the second run's dump
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
@@ -1234,4 +1253,30 @@ far the largest file.
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
`toolbar icons:` under Verification): a resource path that was never added does not raise an
error, it draws `image-missing`.
- **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries
`server_specified`, set only by the `--server` branch, and `main()` asks only when it is false —
so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and
`run_ui()` need no notion of "no source yet". It is `--server` alone and not "no arguments":
`--multicast` still needs a server to fetch CONFIG from. **Every documented probe passes
`--server`**, which is what makes the feature invisible to the regression net — a probe added
without it would reach the chooser and hang. `gtk_init()` plus a nested `GMainLoop`, and then
`run_ui()`'s `GtkApplication` afterwards in the same process, is a verified combination
(`gtk_init()` is idempotent; running your own loop is the documented GTK4 replacement for the
removed `gtk_dialog_run()`).
- **A session file's name cannot say how its source was reached, so the file says it.**
`<host>_<port>.toml` carries neither the multicast group nor the interface, so a chooser row
could not reconnect to a multicast source at all. `LayoutState::multicast`/`interface_addr` are
written in the **top block, before the first `[[table]]`** — a bare key after a table header is
that table's key in TOML, which is exactly how `enabled` was parsed into the last
`[[calibration]]` record and lost without a word. A file from an older build has neither key and
loads as unicast on the default route. The chooser's list filters to sessions that **name at
least one signal**, because a probe run leaves a file per throwaway port and all of them hold
`signals = ["", "", "", ""]`; `known_sources()` splits the filename at the **last** `_`, since
`sanitize()` turns anything unsafe in a host into one.
- **`save_layout_now()` refuses to write while `signals()` is empty.** A run against a source
whose streamer was down receives no CONFIG, so every slot name stays empty and the save would
overwrite a configured session with `signals = ["", ...]`. The chooser then filters that file
out — a source whose streamer was merely down during one run would silently vanish from the
only list it can be picked from, for ever. Keeping the old file loses nothing: a run that
configured nothing had nothing worth persisting.
- Every source file carries an SPDX MIT header; match it in new files.
+1
View File
@@ -63,6 +63,7 @@ add_executable(udpscope
src/plot_host.cpp
src/export.cpp
src/layout_store.cpp
src/source_dialog.cpp
src/measure.cpp
src/math_channel.cpp
src/ui.cpp
+27
View File
@@ -458,6 +458,33 @@ a math channel's name and that name only exists as a signal once its graph is in
reopened on the same source comes back as the one that was closed — including a signal that was
enabled only to feed a math channel.
**Launched with no `--server`, udpscope asks which source to open.** It used to connect to
`127.0.0.1:8000` because that is the default in `Config` — fine for one streamer, useless for
several, and there was no way to see which sources the app already knew about. The chooser lists
one row per stored session: the server and port it was named after, how many plots and signals it
held, and its multicast group when it had one, newest first. `New host…` is a four-field form —
server, port, multicast group, interface — and nothing more: `--buffer-mb` and the rest are tuning
rather than "which source", and a form that asks for them is a form you have to read before using
the app. Cancel exits before anything is allocated, because declining to choose a source is not a
failure.
It runs **before the model exists**, which is the whole reason it is cheap: no buffer, no socket
and no window until a source is known, and `ScopeModel`, `Acquisition` and `run_ui()` are
untouched by the feature. Every headless probe passes `--server`, so none of them reaches it.
Two things the list had to get right. A session file is named `<host>_<port>.toml` and that name
cannot say whether the source was multicast or which interface it joined on, so the file now
records both — in the top block, before the first table, because a bare key written after a
`[[table]]` header belongs to that table in TOML, which is how the `enabled` key was silently lost
once already. And the list is **filtered to sessions that name a signal**: a development machine
accumulates one file per probe run on a throwaway port — 140 of them here, every one with
`signals = ["", "", "", ""]` — so an unfiltered chooser opens as a wall of ports nobody chose. A
row's `×` forgets a source by deleting its session file, with no confirmation: the gesture is the
confirmation, and what it destroys is one layout the app rewrites on the next clean exit. A session
is **not written** when no CONFIG arrived during the run — otherwise a source whose streamer was
down during one launch would silently vanish from the chooser list because its file was flattened
to empty signal names, which the filter refuses.
**Math channels.** A math channel is derived from the others by a graph of operations and is then
an *ordinary channel*: the model synthesises a signal entry for it, so an enabled one is acquired,
plotted, measured, exported and listed in the signal panel exactly like a streamed signal — and
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,236 @@
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
# Source chooser at startup
Launched with no `--server`, udpscope connects to `127.0.0.1:8000` because that is the
default in `Config`. There is no way to pick a source from the application, and no way to
see which sources it already knows about — a user who runs it against several streamers has
to remember each host and port and retype them. This adds a pre-flight dialog that lists
the sources with a stored session and offers a form for a new one.
## Scope
In: the chooser, the connection form, the stored connection settings a chooser row needs to
reconnect, and the probes that check them.
Out: switching source without restarting; editing a stored source in place (forget it and
re-add); network discovery; a "test connection" button. Each is a separate feature and none
is needed to pick a source at startup.
## When the chooser appears
`Config` gains `bool server_specified = false`, set only by the `--server` branch of
`parse_args()`. `main()` runs the chooser if and only if it is false. `Config::server` keeps
its `127.0.0.1` default, so nothing downstream has to cope with an unset host.
The condition is `--server` alone, not "no arguments": `--multicast` still needs a server to
fetch CONFIG from, so a run that gives a group but no server is a run that must still be
asked which host to ask.
Every headless probe documented in CLAUDE.md passes `--server`, so none of them reaches the
chooser and none changes behaviour. That is the property that makes this safe to add to a
project whose entire regression net is those probes.
## Lifecycle
```
main()
parse_args --server absent?
| yes
choose_source(cfg) gtk_init(), dialog, nested GMainLoop
| false (cancelled) -> return 0
| true (chosen)
ScopeModel(cfg) \
Acquisition(cfg) | unchanged
run_ui(cfg, ...) /
```
The chooser runs before `ScopeModel` exists, so a cancel costs no buffer allocation and no
socket, and `run_ui()`, `ScopeModel`, `Acquisition` and `RowSet` are untouched. `gtk_init()`
followed by a nested `GMainLoop`, and then later `gtk_application_new()` +
`g_application_run()` inside `run_ui()`, works: verified in a throwaway program before this
design was settled (`gtk_init()` is idempotent, and running your own loop is the documented
GTK4 replacement for the removed `gtk_dialog_run()`).
Cancel exits **0**, not an error: declining to choose a source is not a failure.
## Known sources
`layout_store` gains:
```cpp
struct KnownSource {
std::string host;
std::uint16_t port = 0;
std::string multicast; ///< empty = unicast
std::string interface_addr;
int rows = 0; ///< plots the session had
int signals = 0; ///< named slots across those plots
std::int64_t mtime = 0; ///< for the ordering; not shown
};
std::vector<KnownSource> known_sources();
```
It scans `config_dir()` for `*.toml`, splits each stem at the **last** `_` into host and
port (the host part may itself contain `_`, since `layout_path()` replaces unsafe characters
with it), parses the file with the existing `load_layout()`, and keeps the entry only when
at least one row slot names a signal. Sorted by `mtime`, newest first.
This lives in `layout_store` because that file already owns `config_dir()`, the
`<host>_<port>.toml` scheme and the parser. A chooser that re-derived any of the three would
be a second expression of the same decision, which is how two parts of this codebase have
disagreed before.
**The filter is what makes the list usable.** A development machine accumulates one session
file per probe run on a throwaway port — 140 of them at the time of writing, all with
`signals = ["", "", "", ""]`. Requiring a named signal means the list shows sources somebody
actually configured.
`forget_source(const KnownSource&)` deletes the file, for the chooser's `×`.
## Stored connection settings
A session file is named `<host>_<port>.toml`, so its name cannot say whether that source was
multicast or which interface it joined on. `LayoutState` therefore gains:
```cpp
std::string multicast; ///< empty = unicast
std::string interface_addr; ///< empty = default route
```
`save_layout()` emits them, when non-empty, in the **top block** (layout_store.cpp:325-338,
before the first `[[plot]]`). This is not a stylistic choice: in TOML a bare key written
after a table header belongs to that table, which is exactly how the `enabled` key was
parsed into the last `[[calibration]]` record and silently lost. `load_layout()` reads them
under `Section::Top`. A file written by an older build has neither key and loads as unicast.
The values come from `Config` and never change at runtime, so `App` carries them beside
`source_host`/`source_port` and `collect_layout()` writes them back verbatim.
The host in the filename stays the CONFIG server even for a multicast session, as today; the
group is an attribute of that source rather than its identity.
## The chooser dialog
New `src/source_dialog.{hpp,cpp}`:
```cpp
/// Asks which source to open. Returns false if the user declined, in which case `cfg` is
/// untouched and the caller should exit. Fills `cfg.server`/`port`/`multicast`/
/// `interface_addr` on true.
bool choose_source(Config& cfg);
```
A separate file rather than more of `src/ui.cpp`: that file is 7400 lines and already the
largest in the project, and this shares no state with it — it runs before the `App` struct
exists.
A `GtkWindow` titled `udpscope — choose a source`, holding a `GtkListBox` inside a
`GtkScrolledWindow` (the list is unbounded) and an action row:
```
udpscope — choose a source
─────────────────────────────────
127.0.0.1:8000 3 plots, 4 signals ×
10.0.0.5:8000 1 plot, 2 signals ×
10.0.0.5:8001 2 plots, 3 signals, multicast 239.1.2.3 ×
─────────────────────────────────────────────────────────────
[ New host… ] [ Cancel ]
```
A multicast row is titled by its **server**, not by its group: that is the host the session
file is named after and the host CONFIG is fetched from. The group is a line of subtitle.
- Each row: `host:port` in the normal face over a dimmed `N plots, M signals`, with
`multicast GROUP` appended when the session stored one.
- Activating a row — click or Enter, i.e. `GtkListBox::row-activated` — fills `cfg` from
that `KnownSource` and ends the loop with true.
- `×` calls `forget_source()` and removes the row. **No confirmation**: the gesture is the
confirmation, and what it destroys is one session's layout, which the app rewrites on the
next clean exit. It is a `GtkButton` inside the row, so it must not also activate the row;
it is packed at the end and stops there by having its own handler.
- `[New host…]` opens the connection form below.
- `[Cancel]`, `Escape` and the window's close button all end the loop with false.
- With no known sources the list is replaced by a dimmed line (`No saved sources yet.`) and
`[New host…]` takes the initial focus, so the dialog is never an empty box.
## The connection form
A second `GtkWindow`, transient for and modal on the chooser:
| Field | Widget | Default | Empty means |
| --- | --- | --- | --- |
| Server | `GtkEntry` | `127.0.0.1` | — (required) |
| Port | `GtkSpinButton`, 1..65535 | `8000` | — |
| Multicast group | `GtkEntry` | empty | unicast |
| Interface | `GtkEntry` | empty | default route |
`[Connect]` writes the four fields into `cfg` and ends both loops with true; `[Cancel]`
returns to the chooser. `[Connect]` is insensitive while `Server` is empty — the only
validation here. A *malformed* address is left to the acquisition layer, which already
reports it, and the starvation notice covers a source that accepts the socket and then says
nothing; duplicating that check in the dialog would be a second expression of it.
These four fields and no others. `--buffer-mb`, `--max-window`, `--holdoff`,
`--recv-buffer` and `--plots` are tuning rather than "which source", and a form that asks
for them is a form a user has to read before using the app.
Connecting to a host that already has a session file is not a special case: the file is
keyed on host and port, so `run_ui()` loads it exactly as it would have.
## Error handling
| Case | Behaviour |
| --- | --- |
| Cancel / close / Escape | exit 0, before any allocation or socket |
| `config_dir()` missing or unreadable | empty list, `[New host…]` only |
| A session file that fails to parse | `load_layout()` already returns what it parsed; an entry with no named signal is filtered out like any other |
| A chosen source that never answers | unchanged from today: CONNECT times out, the client reconnects, the starvation notice fires |
| `forget_source()` fails to unlink | the row stays; nothing else to do and nothing was lost |
## Verification
Probes, reported on stderr in the style of the existing ones, so this is checked by the same
net as everything else rather than a separate harness.
- `UDPSCOPE_PROBE_SOURCES=1` — print the discovered list and exit without showing the
dialog:
```
[sources] 3 known: 127.0.0.1:8000 plots=3 signals=4 | 10.0.0.5:8000 plots=1 signals=2 |
239.1.2.3:8000 plots=2 signals=3 multicast=239.1.2.3
```
Assertions: only sources with a named signal appear, order is newest first, and a config
directory full of probe leftovers reports them **not at all**.
- `UDPSCOPE_PROBE_PICK_MS=N` — show the chooser, then after N ms activate row 0 and let the
run continue into the real application. A full `xvfb-run` run with **no `--server`** must
therefore reach the scope and emit every existing dump line unchanged — that is the
end-to-end assertion, and it costs no new dump format.
- `UDPSCOPE_PROBE_PICK_MS` with `UDPSCOPE_PROBE_NEWHOST=host:port` — drive `[New host…]`
instead: open the form, fill it, press `[Connect]`. The crash surface of a dialog built
and destroyed is as much the point as the state, the same way `UDPSCOPE_PROBE_FIELDS_MS`
is.
- The multicast round trip, run serially: once with `--server`/`--multicast` to write the
file, then `grep multicast` on it, then once with no `--server` at all, checking the row
reads `multicast` and that `cfg` came back with the group.
CLAUDE.md gains these under Verification plus an invariant for the TOML top-block rule and
the `--server`-absent condition; README.md gains the feature and why the list is filtered.
## Files touched
| File | Change |
| --- | --- |
| `src/config.hpp/.cpp` | `server_specified` flag |
| `src/layout_store.hpp/.cpp` | `KnownSource`, `known_sources()`, `forget_source()`, `multicast`/`interface` keys |
| `src/source_dialog.hpp/.cpp` | new: the chooser and the form |
| `src/main.cpp` | call the chooser when `--server` was absent |
| `src/ui.cpp` | carry multicast/interface on `App`, write them in `collect_layout()` |
| `CMakeLists.txt` | the new source |
| `CLAUDE.md`, `README.md` | the documentation this project requires for both |
+1
View File
@@ -67,6 +67,7 @@ Config parse_args(int argc, char** argv) {
std::exit(0);
} else if (a == "--server" || a.rfind("--server=", 0) == 0) {
c.server = value_of(argc, argv, i, "--server");
c.server_specified = true;
} else if (a == "--port" || a.rfind("--port=", 0) == 0) {
const std::string v = value_of(argc, argv, i, "--port");
const long p = std::strtol(v.c_str(), nullptr, 10);
+5
View File
@@ -16,6 +16,11 @@ struct Config {
std::string multicast; ///< Empty selects unicast.
std::string interface_addr; ///< Local IPv4 to join the group on; empty = default route.
/// Whether `--server` was given. Absent, the application asks which source to open rather
/// than silently taking the default above -- so this is "did the user choose", not "is
/// `server` valid". The default stays a usable value for every other path.
bool server_specified = false;
/// Size of *each* of the two sample buffers, in megabytes. The application
/// always allocates two identical buffers (spec item 1).
double buffer_mb = 200.0;
+74
View File
@@ -3,8 +3,11 @@
#include "layout_store.hpp"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <dirent.h>
#include <fstream>
#include <sstream>
#include <sys/stat.h>
@@ -117,6 +120,66 @@ std::string layout_path(const std::string& host, std::uint16_t port) {
return dir + "/" + sanitize(host) + "_" + std::to_string(port) + ".toml";
}
std::vector<KnownSource> known_sources() {
std::vector<KnownSource> out;
const std::string dir = config_dir();
DIR* d = ::opendir(dir.c_str());
// No directory yet is an empty list, not an error: a first run has never saved anything.
if (d == nullptr) return out;
while (const dirent* e = ::readdir(d)) {
const std::string name = e->d_name;
if (name.size() <= 5 || name.compare(name.size() - 5, 5, ".toml") != 0) continue;
const std::string stem = name.substr(0, name.size() - 5);
// The *last* '_': `sanitize()` replaces anything unsafe in a host with one, so the host part
// may hold them too and a first-match split would return a host cut in half.
const std::size_t cut = stem.rfind('_');
if (cut == std::string::npos || cut + 1 == stem.size()) continue;
char* end = nullptr;
const long port = std::strtol(stem.c_str() + cut + 1, &end, 10);
if (end == nullptr || *end != '\0' || port <= 0 || port > 65535) continue;
const std::string host = stem.substr(0, cut);
// The name must be one `layout_path()` could have written, or the `×` -- which rebuilds it
// through `layout_path()` -- would unlink a different session. `sanitize()` is idempotent on
// any host that already came back out of a filename, so this is also true for hosts whose names
// contain `_` from the sanitizer.
if (sanitize(host) + "_" + std::to_string(port) != stem) continue;
LayoutState st;
const std::string path = dir + "/" + name;
if (!load_layout(path, st)) continue;
KnownSource s;
s.host = host;
s.port = static_cast<std::uint16_t>(port);
s.multicast = st.multicast;
s.interface_addr = st.interface_addr;
s.rows = static_cast<int>(st.rows.size());
for (const LayoutState::Row& row : st.rows) {
for (const std::string& sig : row.signals) {
if (!sig.empty()) ++s.signals;
}
}
if (s.signals == 0) continue; // see the header: a session nobody configured
struct stat sb {};
s.mtime = (::stat(path.c_str(), &sb) == 0) ? static_cast<std::int64_t>(sb.st_mtime) : 0;
out.push_back(std::move(s));
}
::closedir(d);
// Stable, so two files sharing an mtime keep the order the directory gave them rather than
// swapping between runs.
std::stable_sort(out.begin(), out.end(),
[](const KnownSource& a, const KnownSource& b) { return a.mtime > b.mtime; });
return out;
}
bool forget_source(const KnownSource& src) {
// Through `layout_path()` rather than by rebuilding the name here: one expression of the
// scheme. `src.host` came back out of a filename, so it is already sanitized, and `sanitize()`
// keeps alphanumerics, '.', '-' and '_' -- it is idempotent, so the round trip is exact.
return std::remove(layout_path(src.host, src.port).c_str()) == 0;
}
std::string math_node_text(const MathNode& node) {
// The op is written as its index through `math_op_at()`, not as the raw enum value: that accessor
// is the contract the dialog, the engine and this file share, so the format is tied to it rather
@@ -242,6 +305,10 @@ bool load_layout(const std::string& path, LayoutState& out) {
plot_count = std::max(0, std::stoi(value));
} catch (...) {
}
} else if (key == "multicast") {
st.multicast = strip_quote(value);
} else if (key == "interface") {
st.interface_addr = strip_quote(value);
}
} else if (section == Section::Plot && !st.rows.empty()) {
if (key == "signals") {
@@ -325,6 +392,13 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "# udpscope session layout (written by the app; safe to edit)\n";
if (state.window_s > 0.0) o << "window_s = " << state.window_s << "\n";
o << "plots = " << state.rows.size() << "\n";
// Top level, before any table. A bare key written after a `[[table]]` header belongs to that
// table in TOML, which is exactly how `enabled` was once parsed into the last `[[calibration]]`
// record and silently lost.
if (!state.multicast.empty()) o << "multicast = " << toml_escape(state.multicast) << "\n";
if (!state.interface_addr.empty()) {
o << "interface = " << toml_escape(state.interface_addr) << "\n";
}
// Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last
// of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly,
// never saw it and a session's enable state was silently lost.
+28
View File
@@ -37,6 +37,12 @@ struct LayoutState {
/// identity here, because a CONFIG renumbers the channels.
std::vector<std::string> enabled;
/// How this source was reached, so the startup chooser can reconnect to it exactly. The file is
/// named `<host>_<port>.toml` and that name can say neither of these. Empty `multicast` is
/// unicast; empty `interface_addr` is the default route.
std::string multicast;
std::string interface_addr;
/// The derived channels, graphs and all: the editor's work is part of the session, not of the
/// process. A graph is stored as one string per node so the file stays in this store's TOML
/// subset (tables, string arrays, scalars) rather than growing a nested-table parser for it; see
@@ -47,6 +53,28 @@ struct LayoutState {
std::vector<Measure> measures;
};
/// A data source udpscope has a stored session for: one row of the startup chooser.
struct KnownSource {
std::string host; ///< the CONFIG server, even for a multicast session
std::uint16_t port = 0;
std::string multicast; ///< empty = unicast
std::string interface_addr; ///< empty = the default route
int rows = 0; ///< plots the session had
int signals = 0; ///< named slots across those plots
std::int64_t mtime = 0; ///< orders the list; never shown
};
/// Every session file that names at least one signal, newest first.
///
/// The filter is what makes the list usable: a development machine accumulates one session file
/// per probe run on a throwaway port -- 140 of them when this was written -- and every one holds
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured.
std::vector<KnownSource> known_sources();
/// Deletes one source's session file; the chooser's `x`. False means the file was already gone or
/// could not be removed, which costs the caller nothing -- there is no state to roll back.
bool forget_source(const KnownSource& src);
/// One `MathNode` as `kind|source|in_a|in_b|gain|offset|fc|q|lo|hi|x|y`, and back. The kind is the
/// op's *index* into `math_op_at()`, which is why the enum's order is a stored format and not just
/// an implementation detail: reordering it renames every saved graph's operations.
+5
View File
@@ -5,6 +5,7 @@
#include "config.hpp"
#include "model.hpp"
#include "plot_host.hpp"
#include "source_dialog.hpp"
#include "ui.hpp"
#include <cstdio>
@@ -19,6 +20,10 @@ int main(int argc, char** argv) {
return 2;
}
// No `--server` means the user has not said which source to open, so ask. Before the model:
// declining must cost no buffer and no socket, and nothing below has to cope with "no source".
if (!cfg.server_specified && !udpscope::choose_source(cfg)) return 0;
udpscope::ScopeModel model(cfg);
udpscope::RowSet rows;
rows.set_row_count(cfg.plots);
+420
View File
@@ -0,0 +1,420 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source_dialog.hpp"
#include "layout_store.hpp"
#include <gtk/gtk.h>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
namespace udpscope {
namespace {
/// A chooser row's source becomes the connection to open. The interface comes along with the
/// group: it is the local address the join happens on, so storing one without the other would
/// reconnect to the right group on the wrong path.
void apply_source(Config& cfg, const KnownSource& src) {
cfg.server = src.host;
cfg.port = src.port;
cfg.multicast = src.multicast;
cfg.interface_addr = src.interface_addr;
}
/// One line per known source on stderr, in the style of the probes in `ui.cpp`.
void report_sources(const std::vector<KnownSource>& known) {
std::fprintf(stderr, "[sources] %zu known:", known.size());
for (const KnownSource& s : known) {
std::fprintf(stderr, " %s:%u plots=%d signals=%d", s.host.c_str(), s.port, s.rows, s.signals);
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
if (!s.interface_addr.empty()) {
std::fprintf(stderr, " interface=%s", s.interface_addr.c_str());
}
std::fprintf(stderr, " |");
}
std::fprintf(stderr, "\n");
std::fflush(stderr);
}
/// The chooser's own state for the life of its nested loop. A plain local in `choose_source()`:
/// the loop runs inside that call, so nothing here outlives the stack frame and there is nothing
/// to free on a handler path.
struct Chooser {
Config* cfg = nullptr;
GMainLoop* loop = nullptr;
bool chosen = false;
GtkWidget* window = nullptr;
GtkWidget* list = nullptr;
std::vector<KnownSource> known;
/// The connection form, created on demand. Members of the chooser rather than a heap struct:
/// the chooser outlives the form by construction, so there is nothing to free on a close path
/// -- and a dialog whose context is freed by a handler is how this project earned a use-after
/// -free in the math editor.
GtkWidget* form = nullptr;
GtkWidget* form_server = nullptr;
GtkWidget* form_port = nullptr;
GtkWidget* form_group = nullptr;
GtkWidget* form_iface = nullptr;
GtkWidget* form_connect = nullptr;
/// `UDPSCOPE_PROBE_PICK_MS`'s timeout, kept so a pick made before it fires can cancel it. The
/// probe clears it when it runs: `g_source_remove()` on an id whose source already returned
/// `G_SOURCE_REMOVE` is a GLib critical, not a no-op.
guint pick_source = 0;
};
void chooser_finish(Chooser& c, bool chosen) {
c.chosen = chosen;
// Destroy the form first: any path that reaches here while the form is up -- a row click, the
// Escape key -- would otherwise leave a window whose four handlers are bound to a dead stack
// frame. `gtk_window_destroy()` rather than `gtk_window_close()`: the pointer is already
// cleared, so the `close-request` handler is not needed here, and `close()` on an unrealized
// window returns having done nothing.
if (c.form != nullptr) {
GtkWidget* w = c.form;
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter
gtk_window_destroy(GTK_WINDOW(w));
}
if (c.window != nullptr) {
GtkWidget* w = c.window;
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
gtk_window_destroy(GTK_WINDOW(w));
}
if (c.loop != nullptr) g_main_loop_quit(c.loop);
}
void chooser_populate(Chooser& c);
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "source-index"));
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
apply_source(*c.cfg, c.known[static_cast<std::size_t>(idx)]);
chooser_finish(c, true);
}
void on_forget_clicked(GtkButton* button, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
forget_source(c.known[static_cast<std::size_t>(idx)]);
// Repopulate rather than remove one row: the rows carry their index into `known`, and dropping
// an entry renumbers every later one. Re-reading is also the honest answer to "did it go".
chooser_populate(c);
}
/// Fills the list box from `known_sources()`. Called again after a forget.
void chooser_populate(Chooser& c) {
while (GtkWidget* child = gtk_widget_get_first_child(c.list)) {
gtk_list_box_remove(GTK_LIST_BOX(c.list), child);
}
c.known = known_sources();
if (c.known.empty()) {
GtkWidget* empty = gtk_label_new("No saved sources yet.");
gtk_widget_add_css_class(empty, "dim-label");
gtk_widget_set_margin_top(empty, 24);
gtk_widget_set_margin_bottom(empty, 24);
// GTK4 auto-wraps any non-row widget appended to a `GtkListBox` in a row, so this is
// activatable -- but the wrapped row carries no `"source-index"` data, `g_object_get_data()`
// returns null, the index reads 0, and `on_row_activated`'s bounds check refuses it.
gtk_list_box_append(GTK_LIST_BOX(c.list), empty);
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_NONE);
return;
}
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_SINGLE);
for (std::size_t i = 0; i < c.known.size(); ++i) {
const KnownSource& s = c.known[i];
GtkWidget* row = gtk_list_box_row_new();
g_object_set_data(G_OBJECT(row), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
gtk_widget_set_margin_start(line, 12);
gtk_widget_set_margin_end(line, 6);
gtk_widget_set_margin_top(line, 6);
gtk_widget_set_margin_bottom(line, 6);
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_hexpand(text, TRUE);
// The *server*, even for a multicast session: that is the host the file is named after and
// the one CONFIG is fetched from. Titling the row by the group would name it after something
// that is not its identity.
const std::string title = s.host + ":" + std::to_string(s.port);
GtkWidget* title_label = gtk_label_new(title.c_str());
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(text), title_label);
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
if (!s.multicast.empty()) sub += ", multicast " + s.multicast;
GtkWidget* sub_label = gtk_label_new(sub.c_str());
gtk_widget_add_css_class(sub_label, "dim-label");
gtk_widget_set_halign(sub_label, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(text), sub_label);
gtk_box_append(GTK_BOX(line), text);
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
gtk_widget_set_tooltip_text(forget, "Forget this source (deletes its saved session)");
gtk_widget_add_css_class(forget, "flat");
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
g_object_set_data(G_OBJECT(forget), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
g_signal_connect(forget, "clicked", G_CALLBACK(on_forget_clicked), &c);
gtk_box_append(GTK_BOX(line), forget);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
gtk_list_box_append(GTK_LIST_BOX(c.list), row);
}
}
void form_close(Chooser& c) {
if (c.form == nullptr) return;
GtkWidget* w = c.form;
c.form = nullptr; // before the close, so the handler cannot re-enter
gtk_window_close(GTK_WINDOW(w));
}
void on_form_server_changed(GtkEditable* entry, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
if (c.form_connect == nullptr) return;
// The only validation here. A *malformed* address is the acquisition layer's to report -- it
// already does, and the starvation notice covers a source that takes the socket and says
// nothing -- so checking it twice would be two expressions of one rule.
gtk_widget_set_sensitive(c.form_connect, gtk_entry_buffer_get_length(
gtk_entry_get_buffer(GTK_ENTRY(entry))) > 0);
}
void on_form_connect(GtkButton*, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server));
if (server == nullptr || server[0] == '\0') return;
c.cfg->server = server;
c.cfg->port = static_cast<std::uint16_t>(
gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(c.form_port)));
c.cfg->multicast = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
c.cfg->interface_addr = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
form_close(c);
chooser_finish(c, true);
}
/// A labelled row for the form's grid.
GtkWidget* form_field(GtkWidget* grid, int row, const char* label, GtkWidget* widget) {
GtkWidget* l = gtk_label_new(label);
gtk_widget_set_halign(l, GTK_ALIGN_END);
gtk_grid_attach(GTK_GRID(grid), l, 0, row, 1, 1);
gtk_widget_set_hexpand(widget, TRUE);
gtk_grid_attach(GTK_GRID(grid), widget, 1, row, 1, 1);
return widget;
}
void open_form(Chooser& c) {
if (c.form != nullptr) {
gtk_window_present(GTK_WINDOW(c.form));
return;
}
c.form = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(c.form), "New host");
gtk_window_set_transient_for(GTK_WINDOW(c.form), GTK_WINDOW(c.window));
gtk_window_set_modal(GTK_WINDOW(c.form), TRUE);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
gtk_widget_set_margin_top(box, 12);
gtk_widget_set_margin_bottom(box, 12);
gtk_window_set_child(GTK_WINDOW(c.form), box);
GtkWidget* grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
gtk_box_append(GTK_BOX(box), grid);
c.form_server = form_field(grid, 0, "Server", gtk_entry_new());
gtk_editable_set_text(GTK_EDITABLE(c.form_server), "127.0.0.1");
// A spin button holds no decimals by default, which is right for a port and wrong for every
// other numeric field in this project -- see the math dialog's fields.
c.form_port = form_field(grid, 1, "Port", gtk_spin_button_new_with_range(1, 65535, 1));
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), 8000);
c.form_group = form_field(grid, 2, "Multicast group", gtk_entry_new());
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_group), "empty = unicast");
c.form_iface = form_field(grid, 3, "Interface", gtk_entry_new());
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_iface), "empty = default route");
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
form_close(*static_cast<Chooser*>(user));
}),
&c);
c.form_connect = gtk_button_new_with_label("Connect");
g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c);
gtk_box_append(GTK_BOX(actions), cancel);
gtk_box_append(GTK_BOX(actions), c.form_connect);
gtk_box_append(GTK_BOX(box), actions);
g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &c);
// `close-request` clears the pointer, and only a *close* emits it: `gtk_window_destroy()` does
// not, which is how the math dialog once kept presenting a finalized window.
g_signal_connect(c.form, "close-request", G_CALLBACK(+[](GtkWindow*, gpointer user) {
static_cast<Chooser*>(user)->form = nullptr;
return FALSE;
}),
&c);
gtk_window_present(GTK_WINDOW(c.form));
gtk_widget_grab_focus(c.form_server);
}
/// What the pick probes report: the connection the chooser came back with. One helper rather than
/// the same four lines in each branch, since both probes assert against this exact line.
void report_pick(const Chooser& c) {
std::fprintf(stderr, "[sources] picked %s:%u multicast=%s interface=%s\n", c.cfg->server.c_str(),
c.cfg->port, c.cfg->multicast.c_str(), c.cfg->interface_addr.c_str());
std::fflush(stderr);
}
gboolean on_chooser_close(GtkWindow*, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
c.window = nullptr; // GTK destroys it for us on the default handler
chooser_finish(c, false);
return FALSE;
}
gboolean on_chooser_key(GtkEventControllerKey*, guint keyval, guint, GdkModifierType,
gpointer user) {
if (keyval != GDK_KEY_Escape) return FALSE;
chooser_finish(*static_cast<Chooser*>(user), false);
return TRUE;
}
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
/// dialog built, filled and destroyed is as much the point as the values, the same way
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
gboolean pick_probe(gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
// Every path below returns `G_SOURCE_REMOVE`, so the source is gone the moment this returns and
// its id is stale. Clear it here rather than on each path: `choose_source()`'s cancel-the-pending
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
c.pick_source = 0;
// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row.
if (const char* spec = g_getenv("UDPSCOPE_PROBE_NEWHOST")) {
open_form(c);
const std::string s = spec;
const std::size_t colon = s.rfind(':');
gtk_editable_set_text(GTK_EDITABLE(c.form_server),
colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str());
if (colon != std::string::npos) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), std::atof(s.c_str() + colon + 1));
}
std::fprintf(stderr, "[sources] form open, connect_sensitive=%d\n",
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
g_signal_emit_by_name(c.form_connect, "clicked");
report_pick(c);
return G_SOURCE_REMOVE;
}
// Check the source list rather than the row count: `gtk_list_box_append` auto-wraps any
// non-row widget in a `GtkListBoxRow`, so `gtk_list_box_get_row_at_index` returns non-null
// even when the only list child is the empty-state placeholder label.
if (c.known.empty()) {
std::fprintf(stderr, "[sources] picked nothing: the list is empty\n");
std::fflush(stderr);
chooser_finish(c, false);
return G_SOURCE_REMOVE;
}
GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(c.list), 0);
// `row-activated`, not `gtk_widget_activate()`: this project has already learned that
// activating a widget takes a path a real click does not (a `GtkButton` holds for its 250 ms
// ACTIVATE_TIMEOUT and then emits into a widget a rebuild has destroyed).
g_signal_emit_by_name(c.list, "row-activated", row);
report_pick(c);
return G_SOURCE_REMOVE;
}
} // namespace
bool choose_source(Config& cfg) {
const std::vector<KnownSource> known = known_sources();
// The discovery half, checkable with no display at all: this returns before `gtk_init()`, so
// it asserts what the list found without needing an X server or a GL context. False means the
// caller exits 0, which is what "list and exit" wants.
if (g_getenv("UDPSCOPE_PROBE_SOURCES") != nullptr) {
report_sources(known);
return false;
}
gtk_init();
Chooser c;
c.cfg = &cfg;
c.window = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(c.window), "udpscope \xe2\x80\x94 choose a source");
gtk_window_set_default_size(GTK_WINDOW(c.window), 420, 320);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_window_set_child(GTK_WINDOW(c.window), box);
GtkWidget* scroller = gtk_scrolled_window_new();
gtk_widget_set_vexpand(scroller, TRUE);
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
c.list = gtk_list_box_new();
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.list), TRUE);
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), c.list);
gtk_box_append(GTK_BOX(box), scroller);
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_start(actions, 12);
gtk_widget_set_margin_end(actions, 12);
gtk_widget_set_margin_top(actions, 12);
gtk_widget_set_margin_bottom(actions, 12);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6");
g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_form(*static_cast<Chooser*>(user));
}),
&c);
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
chooser_finish(*static_cast<Chooser*>(user), false);
}),
&c);
gtk_box_append(GTK_BOX(actions), new_host);
gtk_box_append(GTK_BOX(actions), cancel);
gtk_box_append(GTK_BOX(box), actions);
chooser_populate(c);
if (c.known.empty()) gtk_widget_grab_focus(new_host);
g_signal_connect(c.window, "close-request", G_CALLBACK(on_chooser_close), &c);
GtkEventController* keys = gtk_event_controller_key_new();
g_signal_connect(keys, "key-pressed", G_CALLBACK(on_chooser_key), &c);
gtk_widget_add_controller(c.window, keys);
gtk_window_present(GTK_WINDOW(c.window));
// Keep the timeout id so it can be cancelled if the chooser finishes before it fires. A row
// clicked before UDPSCOPE_PROBE_PICK_MS elapses would otherwise leave an armed source on the
// default main context pointing at the now-gone stack frame.
if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) {
c.pick_source = g_timeout_add(static_cast<guint>(std::atoi(env)), pick_probe, &c);
}
c.loop = g_main_loop_new(nullptr, FALSE);
g_main_loop_run(c.loop);
g_main_loop_unref(c.loop);
c.loop = nullptr;
if (c.pick_source != 0) g_source_remove(c.pick_source);
return c.chosen;
}
} // namespace udpscope
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "config.hpp"
namespace udpscope {
/// Asks which data source to open: a list of the sources with a stored session, and a form for a
/// new one. Returns true with `cfg`'s connection fields filled in, or false if the user declined
/// -- in which case `cfg` is untouched and the caller should exit.
///
/// Runs *before* `ScopeModel` exists, so declining costs no buffer and no socket. It drives its
/// own `gtk_init()` and nested `GMainLoop`; `run_ui()`'s `GtkApplication` comes afterwards in the
/// same process, which works because `gtk_init()` is idempotent.
bool choose_source(Config& cfg);
} // namespace udpscope
+16
View File
@@ -266,6 +266,11 @@ struct App {
/// CONFIG arrived.
std::string source_host;
std::uint16_t source_port = 0;
/// How that source was reached. Unused at runtime -- `Acquisition` has its own copy -- but
/// written into the session file, because `<host>_<port>.toml` can say neither, and the startup
/// chooser needs both to reconnect to a multicast source from a stored row.
std::string source_multicast;
std::string source_interface;
guint layout_save_source = 0;
bool layout_loaded = false;
unsigned signal_generation = ~0u;
@@ -558,6 +563,9 @@ LayoutState collect_layout(App& app) {
// The app's own lists are the pane's source of truth for both of these.
st.math = app.math;
st.measures = app.measures;
// The connection is the session's too: the file's name carries only host and port.
st.multicast = app.source_multicast;
st.interface_addr = app.source_interface;
return st;
}
@@ -565,6 +573,12 @@ LayoutState collect_layout(App& app) {
/// stderr; success is silent -- a save per click must not narrate.
void save_layout_now(App& app) {
if (app.source_port == 0) return;
// Nothing is known about this source yet -- no CONFIG ever arrived -- so every slot name is
// empty and this would overwrite a configured session with `signals = ["", ...]`. The startup
// chooser filters such a file out of its list, so a source whose streamer was down during one
// run would silently vanish from the only place it can be picked. Keeping the old file loses
// nothing: this run configured nothing.
if (app.model->signals().empty()) return;
if (!save_layout(layout_path(app.source_host, app.source_port), collect_layout(app))) {
std::fprintf(stderr, "[udpscope] failed to write the layout file\n");
std::fflush(stderr);
@@ -7159,6 +7173,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
// Which data source this session talks to: the persisted-layout file's name.
app.source_host = cfg.server;
app.source_port = cfg.port;
app.source_multicast = cfg.multicast;
app.source_interface = cfg.interface_addr;
GtkApplication* application =
// `NON_UNIQUE`: a scope is the kind of program a user runs twice side by side -- one