diff --git a/CHANGELOG.md b/CHANGELOG.md index 248d9d6..16e3f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## [2.0.2] - unreleased + +### Added + +- **`parallel_files`** per folder module (1 to 16, default 1): that many + files of a batch in flight at once, each its own lidi transfer, spread + over the lidi ports. `lidi.max_clients` now defaults to what the modules + can have in flight (parallel files included) plus 2. +- **`dyode check`** compares the installed `dyode-in/out.service` with + config.yaml: a unit waiting for another network card than the config's + (it would never start), the wrong command for the side, a different + config path. It also suggests more lidi ports for the box's CPUs, and + warns when an explicit `max_clients` is below what the modules need. +- Reference systemd units in `packaging/systemd/reference/` (v1 and v2, + both sides), kept identical to the generator by a test. +- The guided setup suggests the lidi port count from the CPU count and asks + `parallel_files` on the folder module's Advanced screen. +- **`dyode setup`**, the guided setup DYODE (Python) 1.5 had, back in dialog + boxes (ncurses/whiptail look, drawn by the binary itself, so it works from + the offline bundle, over ssh and on serial consoles). Plain numbered + questions when there is no usable terminal, or with `--plain`. Esc goes + back on every screen; the result is checked by DYODE's own config loader + before it is written, old files are backed up, imported files keep every + option the wizard does not ask about. New screens for the lidi ports + (suggested from the CPU count) and rate cap, warnings for the diode card + (no link, default route, wrong address), and a key step. Every module + has an Advanced screen with all the options the loader accepts for it, + each with its default and validation. +- `install.sh` asks offline or online first (in the same dialog look, when + neither flag is given on a terminal), then runs the guided setup on every + interactive install (`--no-wizard` skips it, `--import-config FILE` + starts from a file), and writes the systemd unit from the config it + produced. + ## [2.0.1] - 2026-10-26 ### Added diff --git a/Cargo.toml b/Cargo.toml index d8a565e..a4cf15a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dyode" -version = "2.0.1" +version = "2.0.2" edition = "2024" rust-version = "1.85" license = "GPL-3.0-or-later" diff --git a/README.md b/README.md index 8fa7650..1b1c20f 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,20 @@ and run `sudo ./install.sh --offline --side in|out` from it: see ```bash cargo build --release # -> target/release/dyode sudo ./install.sh --side in # on the input box -sudo ./install.sh --side out # on the output box -dyode interfaces # NICs and MAC addresses, for config.yaml -sudo $EDITOR /etc/dyode/config.yaml # SAME file on both boxes -sudo dyode new-key -c /etc/dyode/config.yaml # on ONE box; copy dyode.key to the other -sudo dyode check -c /etc/dyode/config.yaml --side in +sudo ./install.sh --side out --import-config /media/usb/config.yaml # output box +``` + +On a terminal, `install.sh` first asks whether to install offline or +online, then runs the guided setup (`dyode setup`): dialog boxes for the +diode network card, addresses, lidi ports, modules (each with an Advanced +screen) and the shared key. It writes `/etc/dyode/config.yaml`, checked +by DYODE's own loader, and the systemd unit is generated from it. Set up +one box, then import its config.yaml on the other. `dyode setup` can be +run again at any time; `--no-wizard` skips it, and `dyode setup --plain` +asks plain questions instead of drawing dialogs. Then: + +```bash +sudo dyode check -c /etc/dyode/config.yaml --side in # also compares the installed unit sudo systemctl enable --now dyode-in # dyode-out on the output box sudo ./install.sh --side out --firewall-only # output box, once config.yaml is final ``` @@ -65,8 +74,9 @@ lidi parameters must match. |---|---| | `dyode in` / `dyode out` | run the input / output side (v1, lidi) | | `dyode serial-in` / `serial-out` | run DYODE v2 (Modbus over serial) | +| `dyode setup` | guided setup in dialog boxes (or `--plain` questions) | | `dyode new-key` | create the shared message key | -| `dyode check` | validate config.yaml and the key, list endpoints | +| `dyode check` | validate config.yaml, the key and the installed unit; throughput hints | | `dyode lidi-config` | print the lidi parameters derived from config.yaml | | `dyode interfaces` | list NICs with MAC, state and IPv4 | | `dyode systemd-unit --side in\|out` | print the systemd unit | diff --git a/config.example.yaml b/config.example.yaml index 80a87e5..f6f0051 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -30,7 +30,9 @@ dyode_out: lidi: ports: [5000] # UDP ports through the diode; one RaptorQ # encode/decode thread per port. More ports = - # more throughput on multi-core boxes. + # more throughput on multi-core boxes: about one + # per CPU core minus one, up to 4, sized for the + # SMALLER box ('dyode check' suggests a list). # mtu: 1500 # of the diode link; 9000 for jumbo frames # block: 220000 # RaptorQ block size, bytes # repair: 5 # repair packets, % of each block (lidi adds 2 @@ -39,7 +41,8 @@ lidi: # transfer in flight with it. Raise on a lossy # link. # heartbeat: 10 # seconds; the output box warns when it stops - # max_clients: auto # simultaneous transfers (default: modules + 2) + # max_clients: auto # simultaneous transfers (default: one per module, + # parallel_files per folder module, plus 2) # mode: mmsg # native | msg | mmsg (sendmmsg/recvmmsg) # reset_timeout: 2 # receiver: decoder reset after this much silence # abort_timeout: 120 # receiver: drop a transfer idle this long (0 = never) @@ -77,6 +80,10 @@ modules: # optional: settle: 2 seconds a file must be unchanged before sending # optional: rescan: 30 seconds between full rescans # optional: bitrate: 100 Mbit/s cap for this module (default: none) + # optional: parallel_files: 1 + # files of a batch sent at once, each its own + # lidi transfer (1 to 16). Raise it with + # several lidi ports, for many files. # optional: keep_sent_hours: 24 # sent files are moved to .dyode_sent and kept # this long instead of being deleted, because a diff --git a/install.sh b/install.sh index ac51f08..1970584 100755 --- a/install.sh +++ b/install.sh @@ -12,7 +12,8 @@ # fetched. The whole bundle is checked against # SHA256SUMS before anything is changed. # --online apt for OS packages; cargo may build the binary -# (default: offline when this folder is a bundle) +# (neither given: asked on an interactive terminal, +# else offline when this folder is a bundle) # --with LIST also install file-sharing services for the received # files: ssh, nfs, smb (comma-separated). Packages only: # configure the shares yourself (never share *.incoming). @@ -28,6 +29,13 @@ # --no-tuning do not install the kernel settings # --firewall-only only (re)apply the firewall rules from config.yaml # --tuning-only only (re)apply the kernel settings +# --no-wizard do not run the guided setup (dyode setup), which +# otherwise runs on every interactive install; an +# existing config.yaml is then kept, or one is made +# from config.example.yaml +# --wizard run it, and say so when the terminal cannot +# --import-config F start the guided setup from this config.yaml (e.g. the +# other box's) # --no-enable write the systemd units but do not enable/start them # --dry-run say what would be done, change nothing # @@ -42,6 +50,7 @@ ROOT="$(cd "$(dirname "$0")" && pwd)" MODE="" WITH="" SKIP_OS=0 SIDE="" VARIANT="v1" BINARY="" PREFIX="/usr/local/bin" ETC="${DYODE_ETC_DIR:-/etc/dyode}" NO_FIREWALL=0 NO_TUNING=0 FIREWALL_ONLY=0 TUNING_ONLY=0 NO_ENABLE=0 DRY_RUN=0 +WIZARD=auto IMPORT_CFG="" SYS_ETC="${DYODE_ETC:-/etc}" say() { printf '%s\n' "$*"; } @@ -62,6 +71,9 @@ while [ $# -gt 0 ]; do --firewall-only) FIREWALL_ONLY=1; shift ;; --tuning-only) TUNING_ONLY=1; shift ;; --no-enable) NO_ENABLE=1; shift ;; + --wizard) WIZARD=yes; shift ;; + --no-wizard) WIZARD=no; shift ;; + --import-config) IMPORT_CFG="${2:-}"; WIZARD=yes; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; --offline) MODE=offline; shift ;; --online) MODE=online; shift ;; @@ -109,8 +121,44 @@ SUPPORTED="Ubuntu 24.04 and 26.04, Debian 12 and 13 / Raspberry Pi OS bookworm a is_bundle() { [ -f "$ROOT/SHA256SUMS" ] && [ -d "$ROOT/bin" ]; } debs_present() { [ -n "$OS_ID" ] && ls "$DEB_DIR"/*.deb >/dev/null 2>&1; } +interactive() { [ -t 0 ] && [ -t 1 ]; } + +# Offline or online, asked before anything is installed: in a dialog box +# by the dyode binary when one is at hand (the bundle's, a local build), +# else as a plain question. +ask_mode() { + local def=online bin="" c ans rc=0 + is_bundle && def=offline + for c in "$BINARY" "$BUNDLE_BIN" "$ROOT/target/release/dyode" "$PREFIX/dyode"; do + if [ -n "$c" ] && [ -x "$c" ] && "$c" version >/dev/null 2>&1; then bin="$c"; break; fi + done + if [ -n "$bin" ]; then + ans="$(mktemp)" + # shellcheck disable=SC2046 + "$bin" setup --ask-install-mode "$ans" --default "$def" $(is_bundle && echo --bundle) || rc=$? + MODE="$(cat "$ans" 2>/dev/null || true)"; rm -f "$ans" + [ "$rc" -eq 3 ] && die "installation cancelled; nothing was changed" + case "$MODE" in offline|online) return 0 ;; esac + fi + local hint="this folder is not an offline bundle" + is_bundle && hint="this folder is an offline bundle" + while :; do + printf 'Install offline (only this folder) or online (apt, cargo)? [%s] (%s): ' "$def" "$hint" + read -r ans || die "no answer; nothing was changed" + case "${ans:-$def}" in + offline|off|o|1) MODE=offline; return 0 ;; + online|on|n|2) MODE=online; return 0 ;; + q|quit) die "installation cancelled; nothing was changed" ;; + esac + done +} + if [ -z "$MODE" ]; then - if is_bundle; then MODE=offline; else MODE=online; fi + if interactive && [ "$FIREWALL_ONLY" -eq 0 ] && [ "$TUNING_ONLY" -eq 0 ]; then + ask_mode + elif is_bundle; then MODE=offline + else MODE=online + fi fi # -------------------------------------------------------- OS packages @@ -346,12 +394,31 @@ say " $PREFIX/dyode ($("$DYODE" version 2>/dev/null || echo '?'))" step "configuration in $ETC" run mkdir -p "$ETC" -if [ -f "$ETC/config.yaml" ]; then +run_wizard() { + [ "$WIZARD" = no ] && return 1 + if ! interactive; then + [ "$WIZARD" = yes ] && warn "--wizard needs an interactive terminal; skipping the guided setup" + return 1 + fi + local args=(setup --side "$SIDE" --variant "$VARIANT" --etc "$ETC") + [ -n "$IMPORT_CFG" ] && args+=(--import-config "$IMPORT_CFG") + [ "$DRY_RUN" -eq 1 ] && args+=(--dry-run) + # The installer writes the systemd unit itself, from what setup wrote. + if DYODE_INSTALLER=1 "$DYODE" "${args[@]}"; then + return 0 + fi + warn "guided setup left before writing; continuing without it" + return 1 +} +if run_wizard; then + if [ "$DRY_RUN" -eq 1 ]; then say " guided setup done (dry run: nothing written)"; else say " $ETC/config.yaml written by the guided setup"; fi +elif [ -f "$ETC/config.yaml" ]; then say " keeping $ETC/config.yaml" else run install -m 0640 "$ROOT/config.example.yaml" "$ETC/config.yaml" say " wrote $ETC/config.yaml from config.example.yaml: EDIT IT (addresses, MACs, modules)," - say " then copy the SAME file to the other box. 'dyode interfaces' lists NICs and MACs." + say " then copy the SAME file to the other box. 'dyode interfaces' lists NICs and MACs," + say " or run the guided setup: sudo dyode setup --side $SIDE" fi if [ "$VARIANT" = v1 ] && [ ! -f "$ETC/dyode.key" ]; then say " no $ETC/dyode.key yet. On ONE box: sudo dyode new-key -c $ETC/config.yaml" @@ -362,7 +429,11 @@ step "systemd" case "$VARIANT/$SIDE" in v1/in) CMD=in ;; v1/out) CMD=out ;; v2/in) CMD=serial-in ;; v2/out) CMD=serial-out ;; esac UNIT="$(mktemp)" SERIAL_FLAG=""; [ "$VARIANT" = v2 ] && SERIAL_FLAG="--serial" -CFG_FOR_UNIT="$ETC/config.yaml"; [ -f "$CFG_FOR_UNIT" ] || CFG_FOR_UNIT="$ROOT/config.example.yaml" +CFG_FOR_UNIT="$ETC/config.yaml" +if [ ! -f "$CFG_FOR_UNIT" ]; then + CFG_FOR_UNIT="$ROOT/config.example.yaml" + [ "$VARIANT" = v1 ] && warn "the unit waits for the example config's NIC; run 'sudo dyode setup' or re-run this installer once config.yaml is right" +fi # shellcheck disable=SC2086 "$DYODE" systemd-unit --side "$SIDE" $SERIAL_FLAG --workdir "$ETC" --binary "$PREFIX/dyode" -c "$CFG_FOR_UNIT" | sed '1d' > "$UNIT" if [ "$DRY_RUN" -eq 1 ]; then diff --git a/packaging/systemd/reference/dyode-in-serial.service b/packaging/systemd/reference/dyode-in-serial.service new file mode 100644 index 0000000..34ec93b --- /dev/null +++ b/packaging/systemd/reference/dyode-in-serial.service @@ -0,0 +1,25 @@ +# Reference only: install.sh and 'dyode setup' write the real +# /etc/systemd/system/dyode-in.service from your config.yaml (its diode +# NIC), with: dyode systemd-unit --side in --serial --workdir /etc/dyode --binary /usr/local/bin/dyode +# This one is for config.example.yaml (input NIC eth0, output NIC eth1). +[Unit] +Description=DYODE input (sending) side (serial-in) +After=network-online.target +Wants=network-online.target +# A diode may sit behind a network that takes long to come back: never give up. +StartLimitIntervalSec=0 + +[Service] +WorkingDirectory=/etc/dyode +ExecStart=/usr/local/bin/dyode serial-in --config /etc/dyode/config.yaml +Restart=always +RestartSec=5 +StandardInput=null +# Creates /var/log/dyode-transfer with the right mode before start. +LogsDirectory=dyode-transfer +LogsDirectoryMode=0750 +# root is needed for the static ARP entry. +User=root + +[Install] +WantedBy=multi-user.target diff --git a/packaging/systemd/reference/dyode-in.service b/packaging/systemd/reference/dyode-in.service new file mode 100644 index 0000000..a6369eb --- /dev/null +++ b/packaging/systemd/reference/dyode-in.service @@ -0,0 +1,30 @@ +# Reference only: install.sh and 'dyode setup' write the real +# /etc/systemd/system/dyode-in.service from your config.yaml (its diode +# NIC), with: dyode systemd-unit --side in --workdir /etc/dyode --binary /usr/local/bin/dyode +# This one is for config.example.yaml (input NIC eth0, output NIC eth1). +[Unit] +Description=DYODE input (sending) side (in) +After=network-online.target +Wants=network-online.target +# Stop and restart with the diode NIC, and do not start without it. +After=sys-subsystem-net-devices-eth0.device +BindsTo=sys-subsystem-net-devices-eth0.device +# A diode may sit behind a network that takes long to come back: never give up. +StartLimitIntervalSec=0 + +[Service] +WorkingDirectory=/etc/dyode +ExecStartPre=/bin/sh -c 'for i in $(seq 1 60); do ip -4 addr show dev eth0 2>/dev/null | grep -q "inet " && exit 0; sleep 1; done; echo "eth0 still has no IPv4 address after 60s" >&2; exit 1' +ExecStart=/usr/local/bin/dyode in --config /etc/dyode/config.yaml +Restart=always +RestartSec=5 +StandardInput=null +# Creates /var/log/dyode-transfer with the right mode before start. +LogsDirectory=dyode-transfer +LogsDirectoryMode=0750 +# root is needed for the static ARP entry. +User=root + +[Install] +WantedBy=multi-user.target +WantedBy=sys-subsystem-net-devices-eth0.device diff --git a/packaging/systemd/reference/dyode-out-serial.service b/packaging/systemd/reference/dyode-out-serial.service new file mode 100644 index 0000000..4969a9b --- /dev/null +++ b/packaging/systemd/reference/dyode-out-serial.service @@ -0,0 +1,25 @@ +# Reference only: install.sh and 'dyode setup' write the real +# /etc/systemd/system/dyode-out.service from your config.yaml (its diode +# NIC), with: dyode systemd-unit --side out --serial --workdir /etc/dyode --binary /usr/local/bin/dyode +# This one is for config.example.yaml (input NIC eth0, output NIC eth1). +[Unit] +Description=DYODE output (receiving) side (serial-out) +After=network-online.target +Wants=network-online.target +# A diode may sit behind a network that takes long to come back: never give up. +StartLimitIntervalSec=0 + +[Service] +WorkingDirectory=/etc/dyode +ExecStart=/usr/local/bin/dyode serial-out --config /etc/dyode/config.yaml +Restart=always +RestartSec=5 +StandardInput=null +# Creates /var/log/dyode-transfer with the right mode before start. +LogsDirectory=dyode-transfer +LogsDirectoryMode=0750 +# root is needed for binding the Modbus server to port 502. +User=root + +[Install] +WantedBy=multi-user.target diff --git a/packaging/systemd/reference/dyode-out.service b/packaging/systemd/reference/dyode-out.service new file mode 100644 index 0000000..973603c --- /dev/null +++ b/packaging/systemd/reference/dyode-out.service @@ -0,0 +1,30 @@ +# Reference only: install.sh and 'dyode setup' write the real +# /etc/systemd/system/dyode-out.service from your config.yaml (its diode +# NIC), with: dyode systemd-unit --side out --workdir /etc/dyode --binary /usr/local/bin/dyode +# This one is for config.example.yaml (input NIC eth0, output NIC eth1). +[Unit] +Description=DYODE output (receiving) side (out) +After=network-online.target +Wants=network-online.target +# Stop and restart with the diode NIC, and do not start without it. +After=sys-subsystem-net-devices-eth1.device +BindsTo=sys-subsystem-net-devices-eth1.device +# A diode may sit behind a network that takes long to come back: never give up. +StartLimitIntervalSec=0 + +[Service] +WorkingDirectory=/etc/dyode +ExecStartPre=/bin/sh -c 'for i in $(seq 1 60); do ip -4 addr show dev eth1 2>/dev/null | grep -q "inet " && exit 0; sleep 1; done; echo "eth1 still has no IPv4 address after 60s" >&2; exit 1' +ExecStart=/usr/local/bin/dyode out --config /etc/dyode/config.yaml +Restart=always +RestartSec=5 +StandardInput=null +# Creates /var/log/dyode-transfer with the right mode before start. +LogsDirectory=dyode-transfer +LogsDirectoryMode=0750 +# root is needed for binding the Modbus server to port 502. +User=root + +[Install] +WantedBy=multi-user.target +WantedBy=sys-subsystem-net-devices-eth1.device diff --git a/src/config.rs b/src/config.rs index 6b71446..16dc8b3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -182,6 +182,8 @@ pub struct FolderConfig { pub batch_timeout: f64, /// Where incoming files are written before verification. pub staging: Option, + /// Files of one batch in flight at once (input side), 1 to 16. + pub parallel_files: u32, /// Keys that only meant something to udpcast. pub ignored: Vec, } @@ -420,11 +422,24 @@ impl Config { .filter(move |m| m.kind.type_name() == ty) } - /// lidi max_clients: configured, or one per module plus two. + /// Transfers the modules may have in flight at once: one per module, + /// plus the extra files of folder modules with `parallel_files`. + pub fn concurrent_transfers(&self) -> u32 { + self.modules + .iter() + .map(|m| match &m.kind { + ModuleKind::Folder(f) => f.parallel_files, + _ => 1, + }) + .sum() + } + + /// lidi max_clients: configured, or what the modules need plus two + /// (a batch summary and a heartbeat may overlap the transfers). pub fn max_clients(&self) -> u32 { self.lidi .max_clients - .unwrap_or(self.modules.len() as u32 + 2) + .unwrap_or(self.concurrent_transfers() + 2) } } @@ -1017,6 +1032,9 @@ fn normalize_lidi(raw: Option<&Value>) -> Result { const UDPCAST_ONLY: [&str; 4] = ["fec", "autostart", "start_timeout", "file_gap"]; +/// The most files one folder module sends at once. +pub const MAX_PARALLEL_FILES: i64 = 16; + fn normalize_module(name: &str, index: u16, props: &Value) -> Result { let Value::Mapping(p) = props else { bail!("module {name:?}: properties must be a mapping"); @@ -1089,6 +1107,15 @@ fn normalize_module(name: &str, index: u16, props: &Value) -> Result { true, )?, staging, + parallel_files: match key(p, "parallel_files") { + None => 1, + Some(v) => match as_int_trunc(v) { + Some(n) if (1..=MAX_PARALLEL_FILES).contains(&n) => n as u32, + _ => bail!( + "module {name:?}: 'parallel_files' must be 1 to {MAX_PARALLEL_FILES}, got {v:?}" + ), + }, + }, ignored: UDPCAST_ONLY .iter() .filter(|k| key(p, k).is_some()) diff --git a/src/folder.rs b/src/folder.rs index 95ecc1d..0d50043 100644 --- a/src/folder.rs +++ b/src/folder.rs @@ -109,73 +109,109 @@ pub fn prune_sent(root: &Path, keep_sent_hours: u64) { } /// Send one batch. Returns the number of files sent. +/// +/// Up to `parallel_files` files are in flight at once, each its own lidi +/// transfer (so its own RaptorQ encoding, spread over the lidi ports). +/// They are started in order and their outcomes collected in order; after +/// a failure nothing new is started, the ones in flight are finished, and +/// the rest wait for the next pass. pub fn send_batch( module: &str, fc: &FolderConfig, link: &ModuleLink, files: &[(String, PathBuf)], ) -> usize { + struct InFlight<'a> { + rel: &'a str, + path: &'a Path, + size: u64, + rx: Result, String>, + } let batch = new_batch_id(); let started = Instant::now(); let mut entries = Vec::new(); let mut sent_bytes = 0u64; let mut failed = 0usize; let todo = &files[..files.len().min(MAX_BATCH_FILES)]; - for (rel, path) in todo { - let Some((file, size, mtime)) = fsafe::open_ready_file(&fc.input, rel) else { - log::warn!( - "skipped {rel}: not a regular file any more (replaced by a link, or removed since the scan)" - ); - Event::new("file_skipped") - .module(module) - .per_file() - .field("path", rel.as_str()) - .field("reason", "not_regular_file") - .emit(); - continue; - }; - let mtime = mtime - .duration_since(SystemTime::UNIX_EPOCH) - .map_or(0.0, |d| d.as_secs_f64()); - let meta = json!({"path": rel, "size": size, "mtime": mtime, "batch": batch}); - log::info!("sending {rel} ({size} bytes)"); - let outcome = link - .send_file(meta.to_string().as_bytes(), file, size) - .and_then(|rx| { - rx.recv() - .map_err(|_| "lidi dropped the transfer".to_string()) - .and_then(|r| r) + let width = fc.parallel_files.max(1) as usize; + let mut next = todo.iter(); + let mut flight: VecDeque = VecDeque::new(); + let mut stopped = false; + loop { + while !stopped && flight.len() < width { + let Some((rel, path)) = next.next() else { + break; + }; + let Some((file, size, mtime)) = fsafe::open_ready_file(&fc.input, rel) else { + log::warn!( + "skipped {rel}: not a regular file any more (replaced by a link, or removed since the scan)" + ); + Event::new("file_skipped") + .module(module) + .per_file() + .field("path", rel.as_str()) + .field("reason", "not_regular_file") + .emit(); + continue; + }; + let mtime = mtime + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0.0, |d| d.as_secs_f64()); + let meta = json!({"path": rel, "size": size, "mtime": mtime, "batch": batch}); + log::info!("sending {rel} ({size} bytes)"); + let rx = link.send_file(meta.to_string().as_bytes(), file, size); + flight.push_back(InFlight { + rel, + path, + size, + rx, }); + } + let Some(f) = flight.pop_front() else { break }; + let outcome = f.rx.and_then(|rx| { + rx.recv() + .map_err(|_| "lidi dropped the transfer".to_string()) + .and_then(|r| r) + }); match outcome { Ok(digest) => { - retire_sent_file(&fc.input, rel, path, fc.keep_sent_hours); - sent_bytes += size; + retire_sent_file(&fc.input, f.rel, f.path, fc.keep_sent_hours); + sent_bytes += f.size; let digest = hex::encode(digest); Event::new("file_sent") .module(module) .per_file() .field("batch", short(&batch)) - .field("path", rel.as_str()) - .field("bytes", size) + .field("path", f.rel) + .field("bytes", f.size) .field("sha256", digest.as_str()) .emit(); - entries.push(json!({"path": rel, "size": size, "sha256": digest})); + entries.push(json!({"path": f.rel, "size": f.size, "sha256": digest})); } Err(e) => { - failed = todo.len() - entries.len(); - log::error!( - "sending {rel} failed ({e}); aborting batch, {failed} file(s) left for the next pass" - ); + failed += 1; + if !stopped { + log::error!( + "sending {} failed ({e}); aborting batch, unsent files are left for the next pass", + f.rel + ); + } else { + log::error!("sending {} failed ({e})", f.rel); + } + stopped = true; Event::new("file_failed") .module(module) .per_file() - .field("path", rel.as_str()) + .field("path", f.rel) .field("reason", e) .emit(); - break; } } } + if stopped { + // Failed ones plus those never started. + failed += next.count(); + } if entries.is_empty() { return 0; } diff --git a/src/lib.rs b/src/lib.rs index 797db98..eed2069 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,7 +23,9 @@ pub mod setup; pub mod supervisor; pub mod timefmt; pub mod transport; +pub mod tui; pub mod watch; +pub mod wizard; #[doc(hidden)] pub mod testutil; diff --git a/src/main.rs b/src/main.rs index 8c04fe5..b0aaa9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,10 @@ Running (both boxes read the same config.yaml): --log-level LEVEL DEBUG, INFO (default), WARNING or ERROR Setting up: + setup guided setup in dialog boxes (plain questions when not on a + terminal): writes config.yaml, the key and the systemd unit. + [--side in|out] [--variant v1|v2] [--etc DIR] [--plain] + [--import-config FILE] [--dry-run] [--no-unit] new-key create the shared message key (once, on ONE box; copy it to the other): [-c config.yaml] [--key-file FILE] check validate config.yaml and the key: [-c FILE] [--side in|out] @@ -200,13 +204,16 @@ fn cmd_check(mut args: Args) { m.name ); } + let mut warnings = unit_warnings(&cfg, side.as_deref(), serial); if serial { println!( " serial: {} @ {} baud (v2: no key needed)", cfg.serial.device, cfg.serial.baudrate ); + print_warnings(&warnings); return; } + warnings.extend(throughput_hints(&cfg)); match auth::load_key(&cfg.auth) { Ok(Some(_)) => println!(" key: {} OK", cfg.auth.key_file.display()), Ok(None) => println!(" key: none (auth.required is false: NOT authenticated)"), @@ -220,10 +227,78 @@ fn cmd_check(mut args: Args) { exit(1); } if side.as_deref() == Some("in") && cfg.network.out_mac.is_none() { - println!( - " warning: dyode_out.mac is not set: the input box cannot reach the output box through a diode" + warnings.insert( + 0, + "dyode_out.mac is not set: the input box cannot reach the output box through a diode" + .into(), ); } + print_warnings(&warnings); +} + +fn print_warnings(w: &[String]) { + for l in w { + println!(" warning: {l}"); + } +} + +/// Compare the installed systemd unit(s) with config.yaml: the side asked +/// for, or every DYODE unit present. +fn unit_warnings(cfg: &config::Config, side: Option<&str>, serial: bool) -> Vec { + let dir = PathBuf::from( + std::env::var("DYODE_SYSTEMD_DIR").unwrap_or_else(|_| "/etc/systemd/system".into()), + ); + let sides: Vec = match side { + Some("in") => vec![Side::In], + Some("out") => vec![Side::Out], + _ => vec![Side::In, Side::Out], + }; + let mut w = Vec::new(); + for s in sides { + let path = dir.join(setup::unit_name(s)); + match std::fs::read_to_string(&path) { + Ok(text) => { + let found = setup::check_unit(&text, s, cfg, serial); + if found.is_empty() { + println!(" unit: {} matches", path.display()); + } + w.extend(found); + } + Err(_) if side.is_some() => w.push(format!( + "{} is not installed: run install.sh (or dyode setup) to create it", + path.display() + )), + Err(_) => {} + } + } + w +} + +/// Suggestions for using the box's cores, never errors. +fn throughput_hints(cfg: &config::Config) -> Vec { + let mut w = Vec::new(); + let cpus = std::thread::available_parallelism().map_or(1, usize::from); + let suggested = dyode::wizard::model::suggested_ports(cpus); + if cfg.lidi.ports.len() < suggested { + let first = cfg.lidi.ports.first().copied().unwrap_or(5000); + w.push(format!( + "lidi uses {} port(s) on a {cpus}-CPU box: one RaptorQ thread per port. For more throughput set \ + lidi.ports: [{}] -- in the config of BOTH boxes, and only if the other box has the cores too", + cfg.lidi.ports.len(), + dyode::wizard::model::port_list(first, suggested) + )); + } + if let Some(m) = cfg.lidi.max_clients { + let need = cfg.concurrent_transfers(); + if m < need { + w.push(format!( + "lidi.max_clients is {m}, but the modules can have {need} transfers in flight (parallel_files \ + included): transfers will wait for each other. Remove max_clients to get {} automatically", + need + 2 + )); + } + } + w } fn cmd_unit(mut args: Args) { @@ -275,6 +350,180 @@ fn cmd_unit(mut args: Args) { ); } +/// Can we create or change files in `dir` (or where it would be created)? +fn writable(dir: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + let mut p = dir; + while !p.exists() { + match p.parent() { + Some(q) => p = q, + None => return false, + } + } + let Ok(c) = std::ffi::CString::new(p.as_os_str().as_bytes()) else { + return false; + }; + // SAFETY: a valid C string. + unsafe { libc::access(c.as_ptr(), libc::W_OK) == 0 } +} + +fn cmd_setup(mut args: Args) { + use dyode::tui; + use dyode::wizard::{self, model, ui}; + let plain = args.flag(&["--plain"]); + let dry_run = args.flag(&["--dry-run"]); + let no_unit = args.flag(&["--no-unit"]); + let etc = PathBuf::from( + args.value(&["--etc"]) + .unwrap_or_else(|| "/etc/dyode".into()), + ); + let side = match args.value(&["--side"]).as_deref() { + None => None, + Some("in") => Some(Side::In), + Some("out") => Some(Side::Out), + Some(o) => die_usage(&format!("--side must be in or out, not {o:?}")), + }; + let variant = match args.value(&["--variant"]).as_deref() { + None => None, + Some("v1") => Some(model::Variant::V1), + Some("v2") => Some(model::Variant::V2), + Some(o) => die_usage(&format!("--variant must be v1 or v2, not {o:?}")), + }; + let import = args.value(&["--import-config"]).map(PathBuf::from); + let preset = wizard::Preset { + side, + variant, + import, + }; + // Used by install.sh: ask offline/online before anything is installed, + // in the same dialog look, and write the answer to a file. + let ask_mode = args.value(&["--ask-install-mode"]); + let mode_default = args.value(&["--default"]); + let bundle = args.flag(&["--bundle"]); + // Hidden: run a key script against a fake box and save every screen. + let demo = args.value(&["--demo-keys"]); + let demo_out = args.value(&["--demo-out"]); + let demo_size = args.value(&["--demo-size"]); + args.done(); + + if let Some(answer) = ask_mode { + let offline_default = mode_default.as_deref() != Some("online"); + let script = demo.clone(); + let res = match (script, plain) { + (Some(keys), _) => { + let mut dui = ui::DialogUi::new(tui::Virtual::new(80, 24, tui::parse_keys(&keys))); + let r = wizard::ask_install_mode(&mut dui, bundle, offline_default); + if let Some(out) = &demo_out { + let body: String = dui + .backend + .shots + .iter() + .map(|(_, f)| format!("{f}\n")) + .collect(); + let _ = std::fs::write(out, body); + } + r + } + (None, false) if tui::Terminal::open().is_ok() => { + let t = tui::Terminal::open().expect("terminal"); + wizard::ask_install_mode(&mut ui::DialogUi::new(t), bundle, offline_default) + } + _ => { + let stdin = std::io::stdin(); + wizard::ask_install_mode( + &mut ui::PlainUi::new(stdin.lock(), std::io::stderr()), + bundle, + offline_default, + ) + } + }; + match res { + Some(mode) => { + if let Err(e) = std::fs::write(&answer, format!("{mode}\n")) { + eprintln!("cannot write {answer}: {e}"); + exit(1); + } + } + None => exit(3), + } + return; + } + + if let Some(script) = demo { + let (w, h) = demo_size + .and_then(|s| { + s.split_once('x') + .and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?))) + }) + .unwrap_or((80, 24)); + let mut env = wizard::demo_env(etc); + env.dry_run = true; + env.write_unit = !no_unit; + let mut dui = ui::DialogUi::new(tui::Virtual::new(w, h, tui::parse_keys(&script))); + let out = wizard::run(&mut dui, &env, preset); + let frames = &dui.backend.shots; + let path = demo_out.unwrap_or_else(|| "setup-demo.html".into()); + let body: String = frames.iter().map(|(_, f)| format!("{f}\n")).collect(); + std::fs::write(&path, body).unwrap_or_else(|e| { + eprintln!("cannot write {path}: {e}"); + exit(1) + }); + println!( + "{} screens to {path} ({})", + frames.len(), + if matches!(out, wizard::Outcome::Quit) { + "ended before writing" + } else { + "reached the end" + } + ); + return; + } + + if !dry_run && !writable(&etc) { + eprintln!( + "dyode setup writes to {} and /etc/systemd: run it with sudo (or try --dry-run).", + etc.display() + ); + exit(1); + } + let mut env = model::Env::real(etc, dry_run); + if no_unit { + env.write_unit = false; + } + let term = if plain { + None + } else { + tui::Terminal::open().ok() + }; + let out = match term { + Some(t) => wizard::run(&mut ui::DialogUi::new(t), &env, preset), + None => { + let stdin = std::io::stdin(); + wizard::run( + &mut ui::PlainUi::new(stdin.lock(), std::io::stdout()), + &env, + preset, + ) + } + }; + match out { + wizard::Outcome::Quit => { + eprintln!("setup left; nothing was written."); + exit(3); + } + wizard::Outcome::Written { done, next, .. } => { + for l in done { + println!("{l}"); + } + println!("\nNext:"); + for l in next { + println!(" {l}"); + } + } + } +} + fn cmd_interfaces(mut args: Args) { let all = args.flag(&["--all"]); args.done(); @@ -373,6 +622,7 @@ fn main() { print!("{}", transport::lidi_toml(&load(p))); } "interfaces" => cmd_interfaces(args), + "setup" => cmd_setup(args), "firewall-rules" => { let p = args.value(&["-c", "--config"]); let iface_only = args.flag(&["--interface"]); diff --git a/src/setup.rs b/src/setup.rs index 6418d6c..05bc964 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -155,6 +155,74 @@ pub fn systemd_unit_text( l.join("\n") } +/// What is wrong between an installed unit and config.yaml, in plain +/// words. `unit` is the unit file's text; `serial` says DYODE v2. +pub fn check_unit( + unit: &str, + side: Side, + cfg: &crate::config::Config, + serial: bool, +) -> Vec { + let mut w = Vec::new(); + let name = unit_name(side); + let bound: Option<&str> = unit + .lines() + .find_map(|l| l.trim().strip_prefix("BindsTo=")) + .map(str::trim); + let exec = unit + .lines() + .find_map(|l| l.trim().strip_prefix("ExecStart=")) + .unwrap_or(""); + let words: Vec<&str> = exec.split_whitespace().collect(); + let command = words.get(1).copied().unwrap_or(""); + let want_cmd = match (side, serial) { + (Side::In, false) => "in", + (Side::Out, false) => "out", + (Side::In, true) => "serial-in", + (Side::Out, true) => "serial-out", + }; + if !command.is_empty() && command != want_cmd { + w.push(format!( + "{name} runs 'dyode {command}', but this is the {want_cmd} side" + )); + } + if !serial { + let iface = match side { + Side::In => &cfg.network.in_interface, + Side::Out => &cfg.network.out_interface, + }; + let want = device_unit(iface); + match bound { + Some(b) if b == want => {} + Some(b) => { + let shown = b + .strip_prefix("sys-subsystem-net-devices-") + .and_then(|x| x.strip_suffix(".device")) + .unwrap_or(b); + w.push(format!( + "{name} waits for the network card {shown}, but config.yaml says {iface}: \ + it will never start (or start on the wrong card). Regenerate it: \ + sudo dyode systemd-unit --side {} > /etc/systemd/system/{name}, or re-run install.sh", + side.as_str() + )); + } + None => w.push(format!("{name} is not tied to the diode card {iface} (no BindsTo=): it may start before the card is up")), + } + } + if let Some(i) = words.iter().position(|w| *w == "--config" || *w == "-c") { + if let Some(p) = words.get(i + 1) { + let unit_cfg = std::fs::canonicalize(p).ok(); + let this = std::fs::canonicalize(&cfg.path).ok(); + if unit_cfg.is_some() && this.is_some() && unit_cfg != this { + w.push(format!("{name} uses {p}, not {}", cfg.path.display())); + } else if unit_cfg.is_none() { + w.push(format!("{name} uses {p}, which does not exist")); + } + } + } + w +} + /// Copy a key file after checking it, readable by root only. pub fn install_key_copy(source: &Path, dest: &Path) -> Result<(), String> { crate::auth::read_key_file(source).map_err(|e| e.to_string())?; @@ -218,6 +286,69 @@ pub fn firewall_rules(cfg: &crate::config::Config) -> Result { mod tests { use super::*; + /// packaging/systemd/reference/ must stay what the code generates. + #[test] + fn reference_units_match_the_generator() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + for (file, side, cmd, iface) in [ + ("dyode-in.service", Side::In, "in", Some("eth0")), + ("dyode-out.service", Side::Out, "out", Some("eth1")), + ("dyode-in-serial.service", Side::In, "serial-in", None), + ("dyode-out-serial.service", Side::Out, "serial-out", None), + ] { + let text = + fs::read_to_string(root.join("packaging/systemd/reference").join(file)).unwrap(); + let body: String = text + .lines() + .skip_while(|l| l.starts_with("# ")) + .map(|l| format!("{l}\n")) + .collect(); + let want = systemd_unit_text( + side, + cmd, + Path::new("/etc/dyode"), + Path::new("/usr/local/bin/dyode"), + iface, + ); + assert_eq!( + body, want, + "{file} is stale: regenerate it with dyode systemd-unit" + ); + } + } + + #[test] + fn unit_checks() { + let d = crate::testutil::TempDir::new(); + let cfg_path = d.path().join("config.yaml"); + fs::write( + &cfg_path, + "dyode_out: {interface: enp2s0}\nmodules:\n a: {type: folder, in: /a, out: /b}\n", + ) + .unwrap(); + let cfg = crate::config::load(&cfg_path).unwrap(); + let unit = |iface: Option<&str>, cmd: &str| { + systemd_unit_text( + Side::Out, + cmd, + d.path(), + Path::new("/usr/local/bin/dyode"), + iface, + ) + }; + assert!(check_unit(&unit(Some("enp2s0"), "out"), Side::Out, &cfg, false).is_empty()); + let w = check_unit(&unit(Some("eth1"), "out"), Side::Out, &cfg, false); + assert!( + w.len() == 1 && w[0].contains("eth1") && w[0].contains("enp2s0"), + "{w:?}" + ); + let w = check_unit(&unit(None, "out"), Side::Out, &cfg, false); + assert!(w[0].contains("not tied"), "{w:?}"); + let w = check_unit(&unit(None, "serial-out"), Side::Out, &cfg, false); + assert!(w.iter().any(|l| l.contains("serial-out")), "{w:?}"); + assert!(check_unit(&unit(None, "serial-out"), Side::Out, &cfg, true).is_empty()); + } + #[test] fn escape() { assert_eq!(systemd_escape("eth0"), "eth0"); diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..897db17 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,615 @@ +//! A small full-screen terminal toolkit in the ncurses / dialog tradition, +//! with no library underneath: raw mode through termios, drawing through +//! ANSI escape sequences. That keeps `dyode setup` working on a box that +//! has nothing but the dyode binary (the offline bundle), on any terminal +//! that speaks VT100 -- a serial console, ssh, the Linux console. +//! +//! Everything is drawn into a [`Canvas`] first. The canvas is then either +//! painted onto the terminal or, for tests and screenshots, kept as is. + +use std::io::{self, Read, Write}; +use std::os::fd::AsRawFd; +use std::time::Duration; + +// -------------------------------------------------------------------------- +// Cells and canvas +// -------------------------------------------------------------------------- + +/// The 8 standard terminal colours. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Color { + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + White, +} + +impl Color { + fn code(self) -> u8 { + self as u8 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Style { + pub fg: Color, + pub bg: Color, + pub bold: bool, +} + +impl Style { + pub const fn new(fg: Color, bg: Color) -> Self { + Self { + fg, + bg, + bold: false, + } + } + pub const fn bold(mut self) -> Self { + self.bold = true; + self + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cell { + pub ch: char, + pub style: Style, +} + +pub struct Canvas { + pub w: usize, + pub h: usize, + pub cells: Vec, + /// Where the terminal cursor should sit (text input), if visible. + pub cursor: Option<(usize, usize)>, +} + +impl Canvas { + pub fn new(w: usize, h: usize, style: Style) -> Self { + Self { + w, + h, + cells: vec![Cell { ch: ' ', style }; w * h], + cursor: None, + } + } + + pub fn fill(&mut self, x: usize, y: usize, w: usize, h: usize, style: Style) { + for row in y..(y + h).min(self.h) { + for col in x..(x + w).min(self.w) { + self.cells[row * self.w + col] = Cell { ch: ' ', style }; + } + } + } + + pub fn put(&mut self, x: usize, y: usize, ch: char, style: Style) { + if x < self.w && y < self.h { + self.cells[y * self.w + x] = Cell { ch, style }; + } + } + + /// Text clipped to `max` columns. Returns the number of columns used. + pub fn text(&mut self, x: usize, y: usize, s: &str, style: Style, max: usize) -> usize { + let mut n = 0; + for ch in s.chars() { + if n >= max { + break; + } + let ch = if ch.is_control() { ' ' } else { ch }; + self.put(x + n, y, ch, style); + n += 1; + } + n + } + + /// Recolour cells without changing their characters (drop shadows). + pub fn shade(&mut self, x: usize, y: usize, w: usize, h: usize, style: Style) { + for row in y..(y + h).min(self.h) { + for col in x..(x + w).min(self.w) { + self.cells[row * self.w + col].style = style; + } + } + } + + /// Plain text rendering, one line per row (tests, screenshots). + pub fn to_text(&self) -> String { + let mut out = String::new(); + for row in 0..self.h { + let line: String = self.cells[row * self.w..(row + 1) * self.w] + .iter() + .map(|c| c.ch) + .collect(); + out.push_str(line.trim_end()); + out.push('\n'); + } + out + } + + /// HTML rendering with colours, for reviewing screens in a browser. + pub fn to_html(&self) -> String { + let css = |c: Color| match c { + Color::Black => "#000000", + Color::Red => "#aa0000", + Color::Green => "#00aa00", + Color::Yellow => "#aa5500", + Color::Blue => "#0000aa", + Color::Magenta => "#aa00aa", + Color::Cyan => "#00aaaa", + Color::White => "#aaaaaa", + }; + let bright = |c: Color| match c { + Color::White => "#ffffff", + Color::Yellow => "#ffff55", + Color::Cyan => "#55ffff", + Color::Red => "#ff5555", + Color::Green => "#55ff55", + other => css(other), + }; + let mut out = String::from("
");
+        for row in 0..self.h {
+            let mut col = 0;
+            while col < self.w {
+                let st = self.cells[row * self.w + col].style;
+                let mut run = String::new();
+                while col < self.w && self.cells[row * self.w + col].style == st {
+                    let ch = self.cells[row * self.w + col].ch;
+                    match ch {
+                        '<' => run.push_str("<"),
+                        '>' => run.push_str(">"),
+                        '&' => run.push_str("&"),
+                        c => run.push(c),
+                    }
+                    col += 1;
+                }
+                let fg = if st.bold { bright(st.fg) } else { css(st.fg) };
+                out.push_str(&format!(
+                    "{run}",
+                    css(st.bg),
+                    if st.bold { ";font-weight:bold" } else { "" }
+                ));
+            }
+            out.push('\n');
+        }
+        out.push_str("
"); + out + } +} + +// -------------------------------------------------------------------------- +// Keys +// -------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Key { + Up, + Down, + Left, + Right, + Home, + End, + PageUp, + PageDown, + Delete, + Enter, + Tab, + BackTab, + Backspace, + Esc, + CtrlC, + CtrlU, + Char(char), + /// The terminal changed size (or nothing happened before the timeout). + Redraw, +} + +/// Decode one key from the start of `buf`; returns it and the bytes used. +/// `more` says whether further bytes may still arrive (a lone ESC is only +/// Escape if nothing follows it quickly). +pub fn decode_key(buf: &[u8]) -> Option<(Key, usize)> { + let b = *buf.first()?; + let simple = match b { + b'\r' | b'\n' => Some(Key::Enter), + b'\t' => Some(Key::Tab), + 127 | 8 => Some(Key::Backspace), + 3 => Some(Key::CtrlC), + 21 => Some(Key::CtrlU), + _ => None, + }; + if let Some(k) = simple { + return Some((k, 1)); + } + if b == 0x1b { + if buf.len() == 1 { + return Some((Key::Esc, 1)); + } + if buf[1] == b'[' || buf[1] == b'O' { + // CSI / SS3: parameters then a final byte in 0x40..=0x7e. + let mut i = 2; + while i < buf.len() && !(0x40..=0x7e).contains(&buf[i]) { + i += 1; + } + if i >= buf.len() { + return Some((Key::Esc, 1)); // incomplete: treat ESC alone + } + let params = &buf[2..i]; + let key = match (buf[i], params) { + (b'A', _) => Key::Up, + (b'B', _) => Key::Down, + (b'C', _) => Key::Right, + (b'D', _) => Key::Left, + (b'H', _) => Key::Home, + (b'F', _) => Key::End, + (b'Z', _) => Key::BackTab, + (b'~', b"1" | b"7") => Key::Home, + (b'~', b"4" | b"8") => Key::End, + (b'~', b"3") => Key::Delete, + (b'~', b"5") => Key::PageUp, + (b'~', b"6") => Key::PageDown, + _ => Key::Redraw, + }; + return Some((key, i + 1)); + } + return Some((Key::Esc, 1)); + } + if b < 32 { + return Some((Key::Redraw, 1)); + } + // UTF-8 + let len = match b { + 0x00..=0x7f => 1, + 0xc0..=0xdf => 2, + 0xe0..=0xef => 3, + _ => 4, + }; + if buf.len() < len { + return None; + } + let ch = std::str::from_utf8(&buf[..len]) + .ok() + .and_then(|s| s.chars().next()) + .unwrap_or('?'); + Some((Key::Char(ch), len)) +} + +// -------------------------------------------------------------------------- +// Backends +// -------------------------------------------------------------------------- + +/// Where frames go and keys come from. +pub trait Backend { + fn size(&self) -> (usize, usize); + fn present(&mut self, canvas: &Canvas) -> io::Result<()>; + fn key(&mut self) -> io::Result; + /// Leave full-screen mode (before printing to the normal screen). + fn suspend(&mut self) {} + fn resume(&mut self) {} +} + +/// The real terminal: raw mode, alternate screen, restored on drop. +pub struct Terminal { + fd: i32, + saved: Option, + pending: Vec, + color: bool, + active: bool, +} + +impl Terminal { + pub fn open() -> io::Result { + let fd = io::stdin().as_raw_fd(); + // SAFETY: plain libc calls on our own stdin. + if unsafe { libc::isatty(fd) } != 1 + || unsafe { libc::isatty(io::stdout().as_raw_fd()) } != 1 + { + return Err(io::Error::other("not a terminal")); + } + let term = std::env::var("TERM").unwrap_or_default(); + if term.is_empty() || term == "dumb" { + return Err(io::Error::other("TERM is dumb or unset")); + } + let color = std::env::var_os("NO_COLOR").is_none(); + let mut t = Self { + fd, + saved: None, + pending: Vec::new(), + color, + active: false, + }; + t.resume(); + if t.saved.is_none() { + return Err(io::Error::other("cannot switch the terminal to raw mode")); + } + Ok(t) + } + + fn raw_on(&mut self) { + let mut tio: libc::termios = unsafe { std::mem::zeroed() }; + // SAFETY: valid fd and termios pointer. + if unsafe { libc::tcgetattr(self.fd, &mut tio) } != 0 { + return; + } + self.saved.get_or_insert(tio); + let mut raw = tio; + // SAFETY: cfmakeraw only writes into raw. + unsafe { libc::cfmakeraw(&mut raw) }; + raw.c_oflag |= libc::OPOST | libc::ONLCR; // keep \n -> \r\n for safety + raw.c_cc[libc::VMIN] = 0; + raw.c_cc[libc::VTIME] = 0; + // SAFETY: valid fd and termios. + unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &raw) }; + } + + fn raw_off(&mut self) { + if let Some(saved) = self.saved { + // SAFETY: restoring what tcgetattr returned. + unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &saved) }; + } + } + + fn wait_input(&self, ms: i32) -> bool { + let mut pfd = libc::pollfd { + fd: self.fd, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: one valid pollfd. + unsafe { libc::poll(&mut pfd, 1, ms) > 0 } + } + + fn read_more(&mut self) { + let mut b = [0u8; 64]; + if let Ok(n) = io::stdin().lock().read(&mut b) { + self.pending.extend_from_slice(&b[..n]); + } + } +} + +fn sgr(st: Style, color: bool) -> String { + if color { + format!( + "\x1b[0;{}3{};4{}m", + if st.bold { "1;" } else { "" }, + st.fg.code(), + st.bg.code() + ) + } else { + // Monochrome: anything on a light background is shown in reverse. + let reverse = matches!( + st.bg, + Color::White | Color::Cyan | Color::Yellow | Color::Green + ); + format!( + "\x1b[0{}{}m", + if st.bold { ";1" } else { "" }, + if reverse { ";7" } else { "" } + ) + } +} + +impl Backend for Terminal { + fn size(&self) -> (usize, usize) { + let mut ws: libc::winsize = unsafe { std::mem::zeroed() }; + // SAFETY: TIOCGWINSZ fills a winsize. + let ok = unsafe { libc::ioctl(io::stdout().as_raw_fd(), libc::TIOCGWINSZ, &mut ws) } == 0; + if ok && ws.ws_col >= 20 && ws.ws_row >= 8 { + (usize::from(ws.ws_col), usize::from(ws.ws_row)) + } else { + (80, 24) + } + } + + fn present(&mut self, c: &Canvas) -> io::Result<()> { + let mut out = String::with_capacity(c.w * c.h * 4); + out.push_str("\x1b[?25l\x1b[H"); + let mut last: Option