diff --git a/os/v3/puppy/console-tui/Cargo.lock b/os/v3/puppy/console-tui/Cargo.lock index 57e16ebe4..412296d2b 100644 --- a/os/v3/puppy/console-tui/Cargo.lock +++ b/os/v3/puppy/console-tui/Cargo.lock @@ -249,6 +249,7 @@ name = "onebit-console-tui" version = "0.1.0" dependencies = [ "crossterm", + "qrcodegen", "ratatui", ] @@ -290,6 +291,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + [[package]] name = "quote" version = "1.0.47" diff --git a/os/v3/puppy/console-tui/Cargo.toml b/os/v3/puppy/console-tui/Cargo.toml index 2d41ae2ce..f566dbb64 100644 --- a/os/v3/puppy/console-tui/Cargo.toml +++ b/os/v3/puppy/console-tui/Cargo.toml @@ -25,3 +25,4 @@ path = "src/main.rs" [dependencies] ratatui = "0.29" crossterm = "0.28" +qrcodegen = "1.8.0" diff --git a/os/v3/puppy/console-tui/README.md b/os/v3/puppy/console-tui/README.md index 823ea88df..519e225fc 100644 --- a/os/v3/puppy/console-tui/README.md +++ b/os/v3/puppy/console-tui/README.md @@ -29,11 +29,11 @@ facts on every draw. ``` 1bit OS rc-2026-11-03-c21 strix 192.168.50.110 up 2h 13m Secure Boot enabled SoC 43C CONNECT ------------------------------------------------------------------------------- - web UI http://192.168.50.110:13305 - API base http://192.168.50.110:13305/api/v1 - API key press 1 - ssh root@192.168.50.110 - mobile Lemonade Mobile: server http://192.168.50.110:13305, key from 1 + web UI http://192.168.50.110:13305 █▀▀▀▀▀█ ▄ █ ▀█▄▄ + name http://1bit-os.local:13305 (mDNS) █ ███ █ ▄▄▄ █▄ █▄ + api http://192.168.50.110:13305/api/v1 (API key: press 1) █ ▀▀▀ █ ▄█ ▄▄ ▀▄▄ █ + ssh root@192.168.50.110 (your key in /data/ssh/authorized_keys) ▀▀▀▀▀▀▀ ▀▄█▄▀ █▄█ ▀▀▀ + app Lemonade Mobile: server http://192.168.50.110:13305 + that key (the web UI URL as a QR) LEMONADE -------------------- GPU ---------------------------- NPU ---------------------- MODELS ---------------------- FANS / TEMPS ------------------- DATA --------------------- MENU ----------------------------------------------------------------------------------- @@ -74,12 +74,36 @@ The MENU is whole and always on screen at both sizes; the log pane gets whatever | arrows, PgUp/PgDn, Home/End | scroll the log (one keypress = one record, oldest to newest) | | **f** | follow the newest line again | | **r** | re-read the facts now (they refresh every 2 s while the box is coming up, every 3 s after) | -| **q** | stays on this screen: this console never drops to a bare prompt | +| **q** | stays on this screen: this console never drops to a bare prompt. Over ssh (`ssh -t root@ /usr/local/bin/1bit-console-tui`) it quits, as `q` does in every full-screen program - the footer says which of the two you are looking at | Keys 2-7 hand the terminal to `1bit-console-menu --action N` - the *same* code `vm-check.sh`'s `U2 menu actions` and `--selftest` rows exercise. Nothing that needs a typed answer is re-implemented here, so the two surfaces cannot drift, and an action the gate proves is an action the screen has. +## Reaching it from another machine (ssh, links, QR) + +The owner, 2026-11-04: *"due to being headless, can you make the links clickable, because i'm on kvm and i want use +it through ryzen"*. So the same binary runs over ssh - it makes no tty1 assumption - and three things follow from +the terminal it finds itself on: + +* **Clickable URLs** (OSC 8 hyperlinks) on the CONNECT rows when `TERM` is a terminal emulator (`xterm-256color`, + `tmux`, `kitty`, ...). ratatui has no hyperlink style, so `main` writes them *after* the frame, at the cells + `ui::draw` reports in `Link`, with the drawn text as the target: the text and the link cannot disagree, and + `every_hyperlink_covers_the_url_that_is_drawn_there` asserts exactly that off the rendered frame. On the VGA + console (`TERM=linux`), JetKVM and a serial line they are **not** written - there they would be printed as + garbage - and no link is emitted for a URL that wrapping split, because half a URL opens the wrong thing. +* **A QR code** of the web UI URL, to the right of the CONNECT rows, two modules per cell (17 rows for the + appliance's 27-character URL: version 2, ECC L, `qrcodegen`, the only new dependency and one with no + transitive ones), drawn **black on white** because a scanner wants dark-on-light. The drawn code was read back + with zxing-cpp from a PNG of the 240x67 frame and decoded to `http://192.168.50.110:13305` (2026-11-04, by hand); + `a_known_url_renders_to_these_exact_rows` pins the 17 rows literally, so a change to the encoder, the ECC level or + the quiet zone fails the build instead of shipping a code nobody can read. It is only drawn where it + fits: at 80x25 there are no 33 free columns beside the block and squeezing it in would push the menu or the log + off the screen, so there the URL stays plain text - which a phone camera reads too. +* **The mDNS name** `http://1bit-os.local:13305`, next to the address, with `(mDNS)` when the box's own + `avahi-daemon` is running and `(mDNS - avahi not running)` when it is not: the row never promises a name that + will not answer. The responder itself is pi-os3's half of the request (engine #953). + ## What it reads, and why from there Every number comes from where the rest of the image reads it - no second source of truth (`src/facts.rs`): @@ -125,7 +149,7 @@ dynamic one (`readelf` PT_INTERP, `ldd`). ## Tests ```sh -cargo test # 25 tests: the probe parsers + the two renders +cargo test # 43 tests: the probe parsers, the QR drawing (incl. a literal known answer), the links, the two renders cargo run -- --snapshot 240x67 --fixture # the frame tests/snapshots/240x67.txt holds cargo run -- --snapshot 80x25 --fixture cargo run -- --status # the same facts as plain text (what a shell or a gate row can read) diff --git a/os/v3/puppy/console-tui/src/facts.rs b/os/v3/puppy/console-tui/src/facts.rs index 39a5d03e1..b3dc47fff 100644 --- a/os/v3/puppy/console-tui/src/facts.rs +++ b/os/v3/puppy/console-tui/src/facts.rs @@ -99,6 +99,8 @@ pub struct Facts { pub secure_boot: SecureBoot, pub ips: Vec, pub key: Option, + /// avahi-daemon is running, i.e. the `1bit-os.local` name the CONNECT block prints can answer + pub mdns: bool, pub lemond_pid: Option, pub lemond_listening: bool, /// the port is listed in `netstat -lnt` (true while the socket exists, even if nothing answers on it) @@ -133,7 +135,8 @@ impl Facts { secure_boot: secure_boot(), ips: ips(), key: api_key(), - lemond_pid: lemond_pid(), + mdns: false, + lemond_pid: None, lemond_listening: lemond_listening(), lemond_listed: false, gpu: Gpu::default(), @@ -145,6 +148,10 @@ impl Facts { stats: Stats::default(), logs: Vec::new(), }; + // one `ps` for both questions: lemond and the mDNS responder are looked up in the same snapshot + let ps = ps_snapshot(); + f.lemond_pid = pid_of(&ps, "lemond"); + f.mdns = pid_of(&ps, "avahi-daemon").is_some(); f.lemond_listed = Command::new("netstat") .args(["-lnt"]) .output() @@ -307,13 +314,13 @@ pub fn pid_of(ps_out: &str, name: &str) -> Option { }) } -fn lemond_pid() -> Option { - let out = Command::new("ps") - .arg("-eo") - .arg("pid=,comm=") +/// `ps -eo pid=,comm=` once per refresh; a box without `ps` gets an empty string, not a panic. +fn ps_snapshot() -> String { + Command::new("ps") + .args(["-eo", "pid=,comm="]) .output() - .ok()?; - pid_of(&String::from_utf8_lossy(&out.stdout), "lemond") + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default() } pub fn listens_on_13305(netstat: &str) -> bool { @@ -954,6 +961,8 @@ pub fn c16_board() -> Facts { secure_boot: SecureBoot::Enabled, ips: vec!["192.168.50.110".into()], key: Some("93112a22cafee2ad".into()), + // the c16 payload shipped no avahi, which is why the recorded sample shows the name as not answering + mdns: false, lemond_pid: Some(8882), lemond_listening: true, lemond_listed: true, @@ -1126,9 +1135,17 @@ mod tests { fs::create_dir_all(root.join("card0/device")).unwrap(); fs::create_dir_all(root.join("card1/device")).unwrap(); fs::write(root.join("card0/device/mem_info_vram_total"), "0\n").unwrap(); - fs::write(root.join("card1/device/mem_info_vram_total"), "128849018880\n").unwrap(); + fs::write( + root.join("card1/device/mem_info_vram_total"), + "128849018880\n", + ) + .unwrap(); fs::write(root.join("card1/device/mem_info_vram_used"), "2147483648\n").unwrap(); - fs::write(root.join("card1/device/mem_info_gtt_total"), "65498251264\n").unwrap(); + fs::write( + root.join("card1/device/mem_info_gtt_total"), + "65498251264\n", + ) + .unwrap(); fs::write(root.join("card1/device/mem_info_gtt_used"), "536870912\n").unwrap(); let mut g = Gpu::default(); gpu_mem_in(&root.to_string_lossy(), &mut g); diff --git a/os/v3/puppy/console-tui/src/main.rs b/os/v3/puppy/console-tui/src/main.rs index 65d71312f..e83277c50 100644 --- a/os/v3/puppy/console-tui/src/main.rs +++ b/os/v3/puppy/console-tui/src/main.rs @@ -40,6 +40,7 @@ //! Exit: 0 clean (1bit-console then shows the dialog menu), 1 the screen could not be set up. mod facts; +mod qr; mod ui; use std::io::stdout; @@ -72,7 +73,7 @@ fn main() -> ExitCode { " 1bit-console-tui --version print the version", "", "keys: 1-9 actions, arrows/PgUp/PgDn scroll the log, f follow, r refresh, 8 full-screen logs,", - " q stays on this screen (this console never drops to a bare prompt).", + " q quits over ssh and stays on tty1 (the console never drops to a bare prompt).", ] { println!("{l}"); } @@ -121,6 +122,52 @@ fn suspend() { let _ = execute!(stdout(), LeaveAlternateScreen, crossterm::cursor::Show); } +/// Was this process started from an ssh session? `sshd` sets both of these, and neither exists on tty1, which is +/// what makes them the honest question (rather than "is there a terminal", which tty1 answers yes to). +fn ssh_session(conn: Option<&str>, tty: Option<&str>) -> bool { + conn.is_some_and(|v| !v.is_empty()) || tty.is_some_and(|v| !v.is_empty()) +} + +fn over_ssh() -> bool { + ssh_session( + std::env::var("SSH_CONNECTION").ok().as_deref(), + std::env::var("SSH_TTY").ok().as_deref(), + ) +} + +/// OSC 8 hyperlinks are a terminal-emulator feature. The VGA console (TERM=linux) has no clickable anything and +/// would print the escapes; JetKVM and a serial terminal are the same case. +fn hyperlinks_wanted(term: Option<&str>) -> bool { + matches!(term, Some(t) if !t.is_empty() && t != "linux" && t != "dumb") +} + +/// Underline what was drawn and wrap it in OSC 8 with its own text as the target. Written AFTER the frame: ratatui +/// has no hyperlink style, and the two rules that make this safe are that the text is identical to the cells it +/// overwrites and that the link is re-stated on every frame (the next paint would otherwise drop it). +/// +/// The style is not `\x1b[4m` alone: an SGR with no colour would inherit whatever the last drawn cell left current, +/// so the link is set to the CONNECT rows' own white+bold through the same crossterm commands ratatui uses, and +/// reset after - the cell keeps the pixel it had, plus the underline that says it can be clicked. +fn emit_links(links: &[ui::Link]) { + if links.is_empty() { + return; + } + use crossterm::cursor::MoveTo; + use crossterm::style::{Attribute, Color, Print, SetAttribute, SetForegroundColor}; + let mut out = stdout(); + for l in links { + let _ = execute!( + out, + MoveTo(l.x, l.y), + SetForegroundColor(Color::White), + SetAttribute(Attribute::Bold), + SetAttribute(Attribute::Underlined), + Print(format!("\x1b]8;;{u}\x1b\\{u}\x1b]8;;\x1b\\", u = l.text)), + SetAttribute(Attribute::Reset), + ); + } +} + fn resume(t: &mut Terminal>) { let _ = execute!(stdout(), EnterAlternateScreen); let _ = enable_raw_mode(); @@ -168,6 +215,8 @@ fn run() -> ExitCode { }); let mut app = App::new(Facts::collect()); + // asked once: sshd sets SSH_CONNECTION/SSH_TTY for the whole session, tty1 respawn sets neither + app.ssh = over_ssh(); let mut last_spin = Instant::now(); let rc = loop { while let Ok(f) = rx.try_recv() { @@ -184,9 +233,15 @@ fn run() -> ExitCode { app.spin = app.spin.wrapping_add(1); last_spin = Instant::now(); } - if term.draw(|f| ui::draw(f, &app)).is_err() { + let mut links = Vec::new(); + if term.draw(|f| ui::draw(f, &app, &mut links)).is_err() { break 1; } + // over ssh the screen is a terminal emulator, so the CONNECT URLs can be clickable (OSC 8). On the VGA + // console (TERM=linux) the same bytes would print as garbage - or worse, as text nobody can click. + if hyperlinks_wanted(std::env::var("TERM").ok().as_deref()) { + emit_links(&links); + } if !event::poll(Duration::from_millis(250)).unwrap_or(false) { continue; @@ -291,6 +346,10 @@ fn handle( KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => { if app.full_logs { app.full_logs = false; + } else if app.ssh { + // the owner, 2026-11-04: the TUI is used through ryzen over ssh, where q is the normal way out + // of a full-screen program. tty1 keeps the old promise: q never drops that console to a prompt. + return Some(0); } else { app.say("q stays: no bare prompt here (7 shell, 9 reboot)"); } @@ -366,7 +425,8 @@ fn handoff(app: &mut App, term: &mut Terminal> app.modal = Some(Modal::Busy { title: format!("action {n}"), }); - let _ = term.draw(|f| ui::draw(f, &*app)); + let mut links = Vec::new(); + let _ = term.draw(|f| ui::draw(f, &*app, &mut links)); suspend(); let status = std::process::Command::new(facts::MENU) .arg("--action") @@ -382,7 +442,34 @@ fn handoff(app: &mut App, term: &mut Terminal> #[cfg(test)] mod tests { - use super::parse_size; + use super::{hyperlinks_wanted, parse_size, ssh_session}; + + #[test] + fn q_is_the_way_out_only_where_a_prompt_is_harmless() { + // sshd sets both; a tty1 respawn sets neither, and that console must never fall back to a bare prompt + assert!(ssh_session(Some("10.0.0.2 51234 10.0.0.5 22"), None)); + assert!(ssh_session(None, Some("/dev/pts/3"))); + assert!(!ssh_session(None, None)); + assert!(!ssh_session(Some(""), Some(""))); + } + + #[test] + fn hyperlinks_are_for_terminal_emulators_not_the_vga_console() { + for term in [ + "xterm-256color", + "screen-256color", + "tmux-256color", + "alacritty", + "kitty", + ] { + assert!(hyperlinks_wanted(Some(term)), "{term}"); + } + // TERM=linux is the appliance's own VGA console and a serial line: the escapes would be printed, not used + for term in ["linux", "dumb", ""] { + assert!(!hyperlinks_wanted(Some(term)), "{term}"); + } + assert!(!hyperlinks_wanted(None)); + } #[test] fn snapshot_sizes() { diff --git a/os/v3/puppy/console-tui/src/qr.rs b/os/v3/puppy/console-tui/src/qr.rs new file mode 100644 index 000000000..649e45019 --- /dev/null +++ b/os/v3/puppy/console-tui/src/qr.rs @@ -0,0 +1,156 @@ +// Copyright 2026 bong-water-water-bong +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The CONNECT block's QR code: the web UI URL in a form a phone can read (the owner, 2026-11-04: "for phones / +//! Lemonade Mobile"). The appliance is headless, so the phone is pointed at the console's own screen. +//! +//! The encoding is `qrcodegen` - Nayuki's reference implementation, one dependency with no transitive ones, so the +//! static binary grows by the encoder and by nothing else. What this module owns is the DRAWING, and it has two +//! rules of its own: +//! +//! * two modules per cell, so the code is half as tall as it is wide: the block must fit beside the CONNECT +//! lines on a 67-row console, and 17 rows is what that costs for a 27-character URL (version 2, ECC L). +//! * the code is drawn on PAPER - black modules on a white background - because a scanner wants dark-on-light, +//! and a terminal's dark background is precisely the inversion that many phone scanners refuse. +//! +//! Nothing here is guessed: the matrix is the encoder's, and what this module draws from it is pinned by two +//! known-answer tests - `blocks_draws_two_module_rows_per_text_row` (a hand-drawn 2x2) and +//! `a_known_url_renders_to_these_exact_rows` (the appliance's own URL, all 17 rows, literal). That the drawn code +//! really scans is not something a test can assert cheaply: it was measured once, on this box, by rendering the +//! 240x67 frame to a PNG and reading it back with zxing-cpp - it returned `http://192.168.50.110:13305` (2026-11-04). +//! Re-run that by hand if the encoder, the ECC level or the quiet zone is ever changed; the literal test below will +//! fail first, which is the point. + +use qrcodegen::{QrCode, QrCodeEcc}; + +/// The quiet zone the standard asks for, in modules, on each side. It is part of the code, not padding: a scanner +/// needs the light border to find the code's edges. +pub const QUIET: usize = 4; + +/// The URL as rows of terminal text, or `None` when the URL does not fit a QR code at all (it cannot: a URL this +/// appliance prints is 30 characters, and version 40 holds 2953 bytes). +pub fn code(url: &str) -> Option> { + let q = QrCode::encode_text(url, QrCodeEcc::Low).ok()?; + let n = q.size(); + let m: Vec> = (0..n) + .map(|y| (0..n).map(|x| q.get_module(x, y)).collect()) + .collect(); + Some(blocks(&m)) +} + +/// The module matrix as text rows, two module rows per text row: `█` both dark, `▀` upper only, `▄` lower only, +/// a space neither. The light cells are spaces and the drawing side paints them on white (`ui::qr_panel`). +pub fn blocks(m: &[Vec]) -> Vec { + let n = m.len(); + let side = n + 2 * QUIET; + let at = |x: usize, y: usize| -> bool { + x >= QUIET && y >= QUIET && x < QUIET + n && y < QUIET + n && m[y - QUIET][x - QUIET] + }; + let mut rows = Vec::new(); + let mut y = 0; + while y < side { + let mut row = String::with_capacity(side); + for x in 0..side { + // an odd module count leaves the last text row with an empty lower half: paper, not ink + let top = at(x, y); + let bottom = y + 1 < side && at(x, y + 1); + row.push(match (top, bottom) { + (true, true) => '█', + (true, false) => '▀', + (false, true) => '▄', + (false, false) => ' ', + }); + } + rows.push(row); + y += 2; + } + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blocks_draws_two_module_rows_per_text_row() { + // a 2x2 matrix, drawn by hand: the quiet zone, then `▀` (dark over light) and `▄` (light over dark) + let m = vec![vec![true, false], vec![false, true]]; + let rows = blocks(&m); + assert_eq!(rows.len(), 5, "{rows:?}"); // 2 modules + 4 quiet on each side, halved + assert_eq!(rows[0], " ".repeat(10)); + assert_eq!(rows[1], " ".repeat(10)); + assert_eq!(rows[2], " ▀▄ "); + assert_eq!(rows[3], " ".repeat(10)); + assert_eq!(rows[4], " ".repeat(10)); + } + + #[test] + fn a_known_url_renders_to_these_exact_rows() { + // The known answer, not a fit check: the appliance's URL in, these 17 rows of 33 cells out. Every rule this + // module owns is visible here - 4 modules of quiet zone on each side (the two blank rows at the top and the + // two at the bottom), two module rows per text row (`▀` upper only, `▄` lower only, `█` both), spaces for + // light. If the encoder, the ECC level (L), the version (2 for 27 characters) or the drawing changes, this + // fails and somebody has to look at a scanner again rather than ship a code nobody can read. + let want = [ + " ", + " ", + " █▀▀▀▀▀█ ▄ █ ▀█▄▄ █▀▀▀▀▀█ ", + " █ ███ █ ▄▄▄ █▄ █▄ █ ███ █ ", + " █ ▀▀▀ █ ▄█ ▄▄ ▀▄▄ █ ▀▀▀ █ ", + " ▀▀▀▀▀▀▀ ▀▄█▄▀ █▄█ ▀▀▀▀▀▀▀ ", + " ▀███▀ ▀▀█▀▄▀█▀▄▀▀█ ▀ ▀ █ ", + " ▀▀█▄▀▄▀ █ ▀ ▄▀▄ ▀▀▄▀ ▀█ ", + " ██▄ ▀▀▀█ ▀▀▄▄▀▄ ▀▀▀▄▀█▀ ", + " █ ▄ ▀ ▀▄▄ █ █ ▀ ▀ █ ▀█ ", + " ▀ ▀▀ ▀ █ █▄▄█▄ █▀▀▀█▄█ ", + " █▀▀▀▀▀█ ▀ ▄▄▄▀ ▄█ ▀ ██▀█▀ ", + " █ ███ █ █▀▄▄▄▀▄██▀███▀█▄▄ ", + " █ ▀▀▀ █ █▀ ▄▄ ▄ ▄▀▄▄▄▄▀ █ ", + " ▀▀▀▀▀▀▀ ▀▀ ▀ ▀ ▀▀▀▀▀▀▀▀ ", + " ", + " ", + ]; + let got = code("http://192.168.50.110:13305").expect("the URL fits"); + assert_eq!(got, want); + } + + #[test] + fn the_appliances_own_url_fits_the_block_the_layout_reserves() { + // measured: 27 characters of URL is a version-2 symbol (25 modules), so 33 wide and 17 rows tall + for url in ["http://192.168.50.110:13305", "http://1bit-os.local:13305"] { + let rows = code(url).expect("the URL fits"); + assert_eq!(rows.len(), 17, "{url}: {} rows", rows.len()); + for r in &rows { + assert_eq!(r.chars().count(), 33, "{url}: {r:?} is not square"); + } + // the quiet zone really is quiet, and the code really is inked + assert!(rows[0].chars().all(|c| c == ' '), "{url}: {}", rows[0]); + assert!(rows + .iter() + .all(|r| r.starts_with(" ") && r.ends_with(" "))); + let ink: usize = rows + .iter() + .map(|r| r.chars().filter(|c| *c != ' ').count()) + .sum(); + assert!(ink > 80, "{url}: only {ink} inked cells"); + } + } + + #[test] + fn a_url_too_long_for_any_code_is_absent_rather_than_wrong() { + assert!(code(&"x".repeat(5000)).is_none()); + assert!(code("http://192.168.50.110:13305").is_some()); + } +} diff --git a/os/v3/puppy/console-tui/src/ui.rs b/os/v3/puppy/console-tui/src/ui.rs index fcbaf5466..3f2ac8951 100644 --- a/os/v3/puppy/console-tui/src/ui.rs +++ b/os/v3/puppy/console-tui/src/ui.rs @@ -90,6 +90,8 @@ pub struct App { pub modal: Option, /// advanced by the event loop ~4x a second while the box is still coming up pub spin: usize, + /// started from an ssh session: `q` is then the way out, which the footer has to say (tty1's does not quit) + pub ssh: bool, /// what just happened, or why the console did not exit, and when it was said (it clears itself after a while) pub message: Option<(String, std::time::Instant)>, } @@ -102,6 +104,7 @@ impl App { full_logs: false, modal: None, spin: 0, + ssh: false, message: None, } } @@ -205,6 +208,13 @@ pub fn connect_block(f: &Facts, spin: &str, width: usize) -> Vec> // wording the gate greps for, so one renderer serves both modes and neither reads as the other. let mut rows = vec![ format!("web UI http://{host}:13305"), + // the name a phone (and Lemonade Mobile) can use instead of the address: pi-os3 makes the box announce + // it via avahi, so when the responder is not running the row says so instead of promising a dead name + if f.mdns { + "name http://1bit-os.local:13305 (mDNS)".to_string() + } else { + "name http://1bit-os.local:13305 (mDNS - avahi not running)".to_string() + }, if keyed { format!("api http://{host}:13305/api/v1 (API key: press 1)") } else { @@ -684,7 +694,17 @@ fn modal_box(m: &Modal) -> (String, Vec, String) { } } -pub fn draw(f: &mut Frame, a: &App) { +/// Where one URL sits on the finished screen, in cells: `text` is both what is shown and what the terminal is +/// asked to open. Only whole URLs are collected - a URL split by wrapping is left plain, because half a link is +/// worse than none - and `main` writes them as OSC 8 hyperlinks when the terminal is not the VGA console. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Link { + pub x: u16, + pub y: u16, + pub text: String, +} + +pub fn draw(f: &mut Frame, a: &App, out_links: &mut Vec) { let area = f.area(); let w = area.width as usize; let h = area.height as usize; @@ -693,9 +713,17 @@ pub fn draw(f: &mut Frame, a: &App) { // header, CONNECT, then the status facts - the "technical information" the owner wants the menu below let mut top: Vec> = Vec::new(); let mut menu: Vec> = Vec::new(); + let mut qr: Option<(u16, Vec>)> = None; if !a.full_logs { top.push(header(&a.facts)); top.extend(connect_block(&a.facts, crate::facts::token(a.spin), w)); + // the QR is drawn to the RIGHT of the CONNECT rows, so the block has to be as tall as the code; when the + // screen cannot hold both (80x25 - the URL there is plain text, which a phone's camera reads too) it is + // absent rather than squeezed into the log pane's rows + if let Some((qw, rows)) = qr_panel(&a.facts, &top[1..], w, h) { + top.resize(top.len().max(2 + rows.len()), Line::from("")); + qr = Some((qw, rows)); + } if wide { top.extend(panel_grid(&a.facts, w)); } else { @@ -709,8 +737,12 @@ pub fn draw(f: &mut Frame, a: &App) { let mut footer: Vec = vec![if a.full_logs { "q back arrows scroll f newest r refresh 1-9 actions".to_string() } else { - "1-9 actions arrows scroll f newest r refresh 8 logs q stays on this screen" - .to_string() + if a.ssh { + "1-9 actions arrows scroll f newest r refresh 8 logs q quits".to_string() + } else { + // the tty1 promise: this console never drops to a bare prompt + "1-9 actions arrows scroll f newest r refresh 8 logs q stays".to_string() + } }]; if let Some((m, _)) = &a.message { footer.push(m.clone()); @@ -753,6 +785,30 @@ pub fn draw(f: &mut Frame, a: &App) { row += 1; render_lines(f, rows[row], &footer); + // the QR goes on top of the blank columns the block left free (it was measured to fit before it is drawn), + // and before the modal, so a dialog is never covered by a code + if let Some((qw, lines)) = &qr { + let rect = Rect { + x: area.x + area.width.saturating_sub(*qw + 1), + y: area.y + 2, + width: (*qw).min(area.width), + height: (lines.len() as u16).min(area.height.saturating_sub(1)), + }; + f.render_widget(Paragraph::new(lines.clone()), rect); + } + + out_links.clear(); + for (i, line) in top.iter().enumerate() { + let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + for (x, url) in urls_in(&text, w) { + out_links.push(Link { + x: x as u16, + y: i as u16, + text: url, + }); + } + } + if let Some(m) = &a.modal { let (title, text, hint) = modal_box(m); let lines: Vec = std::iter::once(title) @@ -783,6 +839,70 @@ pub fn draw(f: &mut Frame, a: &App) { } } +/// The web UI URL as a scannable block, drawn on paper (black on white: a scanner wants dark-on-light) to the +/// right of the CONNECT rows. `None` when the screen cannot hold both - the QR is 33 columns wide for a +/// 27-character URL and 80x25 has no 33 free columns beside the block, so there the URL stays plain text. +fn qr_panel( + f: &Facts, + connect: &[Line<'static>], + w: usize, + h: usize, +) -> Option<(u16, Vec>)> { + let url = format!("http://{}:13305", f.ip()?); + let rows = crate::qr::code(&url)?; + let qw = rows[0].chars().count(); + // the block's first line is the CONNECT rule, which is as wide as the screen: the code only has to clear + // the rows that carry text + let widest = connect + .iter() + .skip(1) + .map(|l| { + l.spans + .iter() + .map(|s| s.content.chars().count()) + .sum::() + }) + .max() + .unwrap_or(0); + // one column of air between the block and the code, and enough rows left for the panels, the nine menu rows, + // three log rows and the footer: a code that pushes the menu off the screen would be the cycle-14 failure again + if w < widest + qw + 2 || h < rows.len() + 12 { + return None; + } + let paper = Style::default().fg(Color::Black).bg(Color::White); + Some(( + qw as u16, + rows.into_iter() + .map(|r| Line::from(Span::styled(r, paper))) + .collect(), + )) +} + +/// Every whole URL on one line of text, with the column it starts at - the CONNECT block's hyperlink cells. +/// +/// A URL that ends exactly on the pane's last column is NOT returned: `wrap_words` hard-breaks a token that is +/// wider than the pane, so such a token may be the first half of one, and a link whose text is half a URL opens +/// the wrong thing (or nothing) when clicked. On an 80- or 240-column screen no CONNECT row reaches that edge, +/// so nothing real is lost; on a 30-column serial line it costs a link instead of opening a broken one. +fn urls_in(text: &str, width: usize) -> Vec<(usize, String)> { + let mut out = Vec::new(); + let mut from = 0; + while let Some(i) = text[from..].find("http://") { + let start = from + i; + let tail = &text[start..]; + let end = tail + .find(|c: char| c.is_whitespace() || c == ')' || c == ',') + .unwrap_or(tail.len()); + let url = &tail[..end]; + let x = text[..start].chars().count(); + if url.len() > "http://".len() && x + url.chars().count() < width { + out.push((x, url.to_string())); + } + from = start + end.max(1); + } + out +} + fn centred(area: Rect, w: u16, h: u16) -> Rect { Rect { x: area.x + area.width.saturating_sub(w) / 2, @@ -839,10 +959,16 @@ fn buffer_text(buf: &Buffer) -> String { /// Render one frame off-screen - what the snapshot tests and `--snapshot` both use. pub fn render_snapshot(a: &App, width: u16, height: u16) -> String { + render_snapshot_with_links(a, width, height).0 +} + +/// The frame and the hyperlink cells it carries, for the tests that check one against the other. +pub fn render_snapshot_with_links(a: &App, width: u16, height: u16) -> (String, Vec) { let backend = TestBackend::new(width, height); let mut t = Terminal::new(backend).expect("test terminal"); - t.draw(|f| draw(f, a)).expect("draw"); - buffer_text(t.backend().buffer()) + let mut links = Vec::new(); + t.draw(|f| draw(f, a, &mut links)).expect("draw"); + (buffer_text(t.backend().buffer()), links) } #[cfg(test)] @@ -1143,17 +1269,151 @@ mod tests { for (w, h) in [(80u16, 25u16), (240, 67)] { let text = render_snapshot(&app(f.clone()), w, h); let flat = text.split_whitespace().collect::>().join(" "); - assert!(flat.contains("key none - anyone here can use it; press 1 to require one"), "{w}x{h}: {text}"); - assert!(!flat.contains("API key: press 1"), "{w}x{h}: keyed wording in blank mode"); + assert!( + flat.contains("key none - anyone here can use it; press 1 to require one"), + "{w}x{h}: {text}" + ); + assert!( + !flat.contains("API key: press 1"), + "{w}x{h}: keyed wording in blank mode" + ); // the extra row must not push the nine actions off the screen - that is the cycle-14 failure in a // different renderer, and the TUI is the surface this image actually puts on tty1 for action in ACTIONS { - assert!(flat.contains(action), "{w}x{h}: action {action:?} not drawn"); + assert!( + flat.contains(action), + "{w}x{h}: action {action:?} not drawn" + ); } } // and keyed mode still carries the strings vm-check's U2 menu connect row greps for - let text = render_snapshot(&app(c16_board()), 80, 25).split_whitespace().collect::>().join(" "); - assert!(text.contains("api http://192.168.50.110:13305/api/v1 (API key: press 1)"), "{text}"); + let text = render_snapshot(&app(c16_board()), 80, 25) + .split_whitespace() + .collect::>() + .join(" "); + assert!( + text.contains("api http://192.168.50.110:13305/api/v1 (API key: press 1)"), + "{text}" + ); + } + + #[test] + fn the_footer_tells_the_truth_about_q_in_both_places() { + // over ssh `q` is what it is in every full-screen program; on tty1 the console must not promise a way to + // a bare prompt. The line is the only place the console says which of the two you are looking at. + let mut ssh = app(c16_board()); + ssh.ssh = true; + let text = render_snapshot(&ssh, 80, 25); + assert!( + text.trim_end() + .lines() + .last() + .unwrap_or("") + .contains("q quits"), + "{text}" + ); + let text = render_snapshot(&app(c16_board()), 80, 25); + assert!( + text.trim_end() + .lines() + .last() + .unwrap_or("") + .contains("q stays"), + "{text}" + ); + } + + #[test] + fn the_mdns_name_is_on_screen_at_both_sizes() { + // the owner, 2026-11-04: "show the mDNS name too". pi-os3 makes the box announce it; the c16 sample this + // fixture records shipped no avahi, so the row must say that rather than promise a name that is not there. + for (w, h) in [(240u16, 67u16), (80, 25)] { + let text = render_snapshot(&app(c16_board()), w, h); + assert!( + text.contains("name http://1bit-os.local:13305"), + "{w}x{h}: no mDNS name:\n{text}" + ); + assert!( + text.contains("avahi not running"), + "{w}x{h}: a dead name is promised silently:\n{text}" + ); + let mut up = c16_board(); + up.mdns = true; + let text = render_snapshot(&app(up), w, h); + assert!(text.contains("(mDNS)"), "{w}x{h}:\n{text}"); + assert!(!text.contains("avahi not running"), "{w}x{h}:\n{text}"); + } + } + + #[test] + fn every_hyperlink_covers_the_url_that_is_drawn_there() { + // the links are written AFTER the frame, at these very cells, so the only thing that makes them safe is + // that the text on screen and the link target are the same string. Asserted off the rendered frame. + for (w, h) in [(240u16, 67u16), (80, 25)] { + let (text, links) = render_snapshot_with_links(&app(c16_board()), w, h); + let lines: Vec<&str> = text.lines().collect(); + let mut seen: Vec = Vec::new(); + for l in &links { + let row = lines[l.y as usize]; + let at: String = row + .chars() + .skip(l.x as usize) + .take(l.text.chars().count()) + .collect(); + assert_eq!( + at, l.text, + "{w}x{h}: link at {},{} does not cover its own URL", + l.x, l.y + ); + seen.push(l.text.clone()); + } + for want in [ + "http://192.168.50.110:13305", + "http://1bit-os.local:13305", + "http://192.168.50.110:13305/api/v1", + ] { + assert!( + seen.iter().any(|s| s == want), + "{w}x{h}: {want} is not a link: {seen:?}" + ); + } + } + } + + #[test] + fn a_url_that_wrapping_split_is_not_a_link() { + // half a URL as the link text would open the wrong thing (or nothing), so only whole tokens are returned + assert_eq!( + urls_in(" web UI http://10.0.0.5:13305", 80), + vec![(9, "http://10.0.0.5:13305".to_string())] + ); + assert_eq!( + urls_in( + " api http://10.0.0.5:13305/api/v1 (API key: press 1)", + 80 + ), + vec![(7, "http://10.0.0.5:13305/api/v1".to_string())] + ); + assert_eq!(urls_in("no links here", 80), Vec::new()); + // a token that fills the pane to its last column may be half a URL: no link rather than a broken one + let row = " web UI http://10.0.0.5:13305"; + let w = row.chars().count() + 1; + assert_eq!( + urls_in(row, w), + vec![(9, "http://10.0.0.5:13305".to_string())] + ); + assert_eq!(urls_in(row, w - 1), Vec::new()); + } + + #[test] + fn the_qr_is_drawn_beside_the_block_only_where_it_fits() { + // 240x67: 33 columns and 17 rows to the right of the CONNECT rows, on paper (black on white) + let text = render_snapshot(&app(c16_board()), 240, 67); + assert!(text.contains('█'), "no QR at 240x67:\n{text}"); + // 80x25: 33 free columns do not exist next to the block, and squeezing the code in would push the nine + // menu rows or the three log rows off the screen. The URL stays plain text. + let text = render_snapshot(&app(c16_board()), 80, 25); + assert!(!text.contains('█'), "the QR took the 80x25 screen:\n{text}"); } #[test] diff --git a/os/v3/puppy/console-tui/tests/snapshots/240x67.txt b/os/v3/puppy/console-tui/tests/snapshots/240x67.txt index b09b5f64f..7e634fe84 100644 --- a/os/v3/puppy/console-tui/tests/snapshots/240x67.txt +++ b/os/v3/puppy/console-tui/tests/snapshots/240x67.txt @@ -1,9 +1,22 @@ 1bit OS rc-2026-11-03-c16 strix 192.168.50.110 up 2h 13m Secure Boot enabled SoC 43C CONNECT --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- web UI http://192.168.50.110:13305 - api http://192.168.50.110:13305/api/v1 (API key: press 1) - ssh root@192.168.50.110 (your key in /data/ssh/authorized_keys) - app Lemonade Mobile: server http://192.168.50.110:13305 + that key + name http://1bit-os.local:13305 (mDNS - avahi not running) + api http://192.168.50.110:13305/api/v1 (API key: press 1) █▀▀▀▀▀█ ▄ █ ▀█▄▄ █▀▀▀▀▀█ + ssh root@192.168.50.110 (your key in /data/ssh/authorized_keys) █ ███ █ ▄▄▄ █▄ █▄ █ ███ █ + app Lemonade Mobile: server http://192.168.50.110:13305 + that key █ ▀▀▀ █ ▄█ ▄▄ ▀▄▄ █ ▀▀▀ █ + ▀▀▀▀▀▀▀ ▀▄█▄▀ █▄█ ▀▀▀▀▀▀▀ + ▀███▀ ▀▀█▀▄▀█▀▄▀▀█ ▀ ▀ █ + ▀▀█▄▀▄▀ █ ▀ ▄▀▄ ▀▀▄▀ ▀█ + ██▄ ▀▀▀█ ▀▀▄▄▀▄ ▀▀▀▄▀█▀ + █ ▄ ▀ ▀▄▄ █ █ ▀ ▀ █ ▀█ + ▀ ▀▀ ▀ █ █▄▄█▄ █▀▀▀█▄█ + █▀▀▀▀▀█ ▀ ▄▄▄▀ ▄█ ▀ ██▀█▀ + █ ███ █ █▀▄▄▄▀▄██▀███▀█▄▄ + █ ▀▀▀ █ █▀ ▄▄ ▄ ▄▀▄▄▄▄▀ █ + ▀▀▀▀▀▀▀ ▀▀ ▀ ▀ ▀▀▀▀▀▀▀▀ + + LEMONADE ---------------------------------------------------------------------- GPU --------------------------------------------------------------------------- NPU ------------------------------------------------------------------------- up (pid 8882) HRX0: AMD Radeon 8060S Graphics amdxdna loaded http://192.168.50.110:13305 74158 MiB free firmware present @@ -51,17 +64,4 @@ LEM 2026-11-03 12:07:41 lemond: POST /api/v1/chat/completions 200 (327 tokens, 3 - - - - - - - - - - - - - -1-9 actions arrows scroll f newest r refresh 8 logs q stays on this screen +1-9 actions arrows scroll f newest r refresh 8 logs q stays diff --git a/os/v3/puppy/console-tui/tests/snapshots/80x25.txt b/os/v3/puppy/console-tui/tests/snapshots/80x25.txt index 82dba989e..c07aeba9f 100644 --- a/os/v3/puppy/console-tui/tests/snapshots/80x25.txt +++ b/os/v3/puppy/console-tui/tests/snapshots/80x25.txt @@ -1,6 +1,7 @@ 1bit OS rc-2026-11-03-c16 strix 192.168.50.110 up 2h 13m Secure Boot enabled CONNECT ----------------------------------------------------------------------- web UI http://192.168.50.110:13305 + name http://1bit-os.local:13305 (mDNS - avahi not running) api http://192.168.50.110:13305/api/v1 (API key: press 1) ssh root@192.168.50.110 (your key in /data/ssh/authorized_keys) app Lemonade Mobile: server http://192.168.50.110:13305 + that key @@ -17,9 +18,8 @@ LEMONADE up 8882 | GPU HRX0 43C | NPU idle | MODELS 2 | FANS controlled 8 Logs (Lemonade, kernel) 9 Reboot / power off LOGS newest ------------------------------------------------------------------- -KRN [ 1.100000] amdgpu 0000:c7:00.0: HRX runtime registered HRX0 KRN [ 1.200000] amdxdna 0000:c7:00.1: firmware loaded LEM 2026-11-03 12:07:38 llama-server: listening on 127.0.0.1:8001 LEM 2026-11-03 12:07:41 lemond: POST /api/v1/chat/completions 200 (327 tokens, 330.75 tok/s) -1-9 actions arrows scroll f newest r refresh 8 logs q stays on this screen +1-9 actions arrows scroll f newest r refresh 8 logs q stays