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>
This commit is contained in:
1 parent
a92a6b5bf0
commit
78065715b6
1 file changed
+236
@@ -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 |
|
||||||
Reference in new issue
Block a user