docs: illustrated LaTeX user guide (docs/guide) with generated screenshots
This commit is contained in:
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# ForceCommand for the throwaway screenshot sshd: never a login shell (no ~/.profile, no byobu).
|
||||
DEMO_HOME=$1 SFTP_SERVER=$2
|
||||
export LC_ALL=C.UTF-8
|
||||
case "$SSH_ORIGINAL_COMMAND" in
|
||||
*sftp*) exec "$SFTP_SERVER" -d "$DEMO_HOME" ;;
|
||||
?*) cd "$DEMO_HOME" && HOME="$DEMO_HOME" exec bash --norc --noprofile -c "$SSH_ORIGINAL_COMMAND" ;;
|
||||
esac
|
||||
cd "$DEMO_HOME" || exit 1
|
||||
export HOME="$DEMO_HOME" PS1='\[\e[1;32m\]andy@atlas\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]\$ '
|
||||
row() { # boxed line, padded on visible width
|
||||
local plain; plain=$(printf '%b' "$1" | sed 's/\x1b\[[0-9;]*m//g')
|
||||
printf '\e[1;36m│\e[0m %b%*s\e[1;36m│\e[0m\n' "$1" $((44 - ${#plain})) ''
|
||||
}
|
||||
printf '\e[1;36m╭──────────────────────────────────────────────╮\e[0m\n'
|
||||
row 'Welcome to \e[1matlas\e[0m · Debian 13 · kernel 6.12'
|
||||
row 'Load \e[32m0.42\e[0m Memory \e[33m61%\e[0m Disk \e[32m38%\e[0m'
|
||||
row 'Last deploy: \e[1m2 hours ago\e[0m by andy'
|
||||
printf '\e[1;36m╰──────────────────────────────────────────────╯\e[0m\n\n'
|
||||
printf '\e[1;32m$\e[0m ls -l --color\n'; ls -l --color=always
|
||||
printf '\n\e[1;32m$\e[0m ./deploy.sh --dry-run\n'
|
||||
for s in "Resolving build" "Uploading artefacts" "Rolling out to 4 nodes" "Health checks"; do
|
||||
printf ' \e[32m✔\e[0m %-26s \e[2m%s\e[0m\n' "$s" "$((RANDOM % 900 + 100))ms"
|
||||
done
|
||||
printf ' \e[33m⚠\e[0m 1 warning: \e[1mnginx.conf\e[0m has a deprecated directive\n'
|
||||
printf ' \e[1;32mDone.\e[0m Nothing was actually deployed (dry run).\n\n'
|
||||
exec bash --norc --noprofile -i
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tiny fake servers for the user-guide screenshots: RAW sensor feed (42023), IRC (42667),
|
||||
GitHub API (42080) and a VNC desktop (42900). Stdlib only. Runs until killed."""
|
||||
|
||||
import json, math, random, socket, socketserver, struct, threading, time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
class Threaded(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
# --- RAW: a chatty sensor --------------------------------------------------------------
|
||||
|
||||
class Raw(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
s = self.request
|
||||
s.sendall(b"SENSOR-7 ready. Commands: STATUS, RESET\n")
|
||||
t = 20.5
|
||||
s.settimeout(0.25)
|
||||
for i in range(400):
|
||||
t += random.uniform(-0.3, 0.3)
|
||||
s.sendall(f"temp={t:.2f}C hum={48 + 4 * math.sin(i / 3):.1f}% seq={i}\n".encode())
|
||||
try:
|
||||
if (cmd := s.recv(256)):
|
||||
s.sendall(b"OK " + cmd.strip() + b"\n")
|
||||
except socket.timeout:
|
||||
pass
|
||||
|
||||
|
||||
# --- IRC: a small, friendly network ----------------------------------------------------
|
||||
|
||||
CHAT = [
|
||||
("ferris", "anyone tried the new Nexus build yet?"),
|
||||
("crabby", "yep, the split panes are lovely"),
|
||||
("ferris", "the serial console support saved my afternoon tbh"),
|
||||
("lin", "does it do jump hosts?"),
|
||||
("crabby", "lin: multiple hops, plus SOCKS5/HTTP proxies"),
|
||||
("ferris", "and it's Rust all the way down now :)"),
|
||||
]
|
||||
|
||||
|
||||
class Irc(socketserver.StreamRequestHandler):
|
||||
def send(self, line):
|
||||
self.wfile.write((line + "\r\n").encode())
|
||||
|
||||
def handle(self):
|
||||
nick = "andy"
|
||||
for raw in self.rfile:
|
||||
line = raw.decode(errors="replace").strip()
|
||||
cmd, _, rest = line.partition(" ")
|
||||
if cmd == "NICK":
|
||||
nick = rest.strip()
|
||||
elif cmd == "USER":
|
||||
self.send(f":irc.demo 001 {nick} :Welcome to the Demo IRC Network, {nick}")
|
||||
self.send(f":irc.demo 375 {nick} :- irc.demo Message of the Day -")
|
||||
self.send(f":irc.demo 372 {nick} :- Be excellent to each other.")
|
||||
self.send(f":irc.demo 376 {nick} :End of /MOTD command.")
|
||||
elif cmd == "JOIN":
|
||||
for room in rest.split()[0].split(","):
|
||||
self.send(f":{nick}!u@demo JOIN {room}")
|
||||
self.send(f":irc.demo 332 {nick} {room} :Nexus Terminal chat · be kind · no pasting walls of logs")
|
||||
self.send(f":irc.demo 353 {nick} = {room} :@ferris +crabby lin {nick} octo rustacean")
|
||||
self.send(f":irc.demo 366 {nick} {room} :End of /NAMES list.")
|
||||
if room == "#nexus":
|
||||
for who, msg in CHAT:
|
||||
self.send(f":{who}!u@demo PRIVMSG #nexus :{msg}")
|
||||
else:
|
||||
self.send(f":octo!u@demo PRIVMSG {room} :borrow checker 1, me 0")
|
||||
elif cmd == "LIST":
|
||||
for room, n, topic in [("#nexus", 42, "Nexus Terminal"), ("#rust", 3110, "Rust"), ("#linux", 905, "Linux"), ("#homelab", 77, "Racks & routers")]:
|
||||
self.send(f":irc.demo 322 {nick} {room} {n} :{topic}")
|
||||
self.send(f":irc.demo 323 {nick} :End of /LIST")
|
||||
elif cmd == "PING":
|
||||
self.send(f":irc.demo PONG irc.demo {rest}")
|
||||
elif cmd == "QUIT":
|
||||
return
|
||||
|
||||
|
||||
# --- GitHub API --------------------------------------------------------------------------
|
||||
|
||||
REPOS = [
|
||||
("andydixon", "nexus-terminal", "SSH, Telnet, Serial, IRC and VNC in one tidy native app", "Rust", 128, False, False, False, 1),
|
||||
("andydixon", "leviathan", "The egui design system Nexus borrows its looks from", "Rust", 64, False, False, False, 3),
|
||||
("andydixon", "dotfiles", "Shell, editor and terminal config", "Shell", 12, False, False, False, 6),
|
||||
("andydixon", "homelab-ansible", "Playbooks for the rack under the stairs", "Jinja", 7, True, False, False, 9),
|
||||
("andydixon", "alchementary-link", "The Wails/Go predecessor to Nexus Terminal", "Go", 31, False, False, True, 40),
|
||||
("alchementary", "website", "Marketing site", "TypeScript", 3, True, False, False, 2),
|
||||
("alchementary", "infra", "Terraform for everything", "HCL", 2, True, False, False, 5),
|
||||
("alchementary", "russh", "Fork with a jump-host fix", "Rust", 0, False, True, False, 15),
|
||||
]
|
||||
WHO = ["Andy Dixon", "ferris", "crabby", "dependabot[bot]"]
|
||||
|
||||
|
||||
def iso(days):
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - days * 86400 - 3600 * 5))
|
||||
|
||||
|
||||
class Github(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def reply(self, obj):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/user":
|
||||
return self.reply({"login": "andydixon"})
|
||||
if self.path.startswith("/user/repos"):
|
||||
page = 1 if "page=1" in self.path else 2
|
||||
if page != 1:
|
||||
return self.reply([])
|
||||
return self.reply([
|
||||
{"full_name": f"{o}/{n}", "name": n, "description": d, "html_url": f"https://github.com/{o}/{n}",
|
||||
"default_branch": "main", "private": p, "fork": f, "archived": a, "language": l,
|
||||
"stargazers_count": s, "pushed_at": iso(age), "updated_at": iso(age), "owner": {"login": o}}
|
||||
for o, n, d, l, s, p, f, a, age in REPOS])
|
||||
if "/commits/" in self.path:
|
||||
i = sum(map(ord, self.path)) % len(WHO)
|
||||
age = next((r[8] for r in REPOS if f"/{r[1]}/" in self.path), 1)
|
||||
return self.reply({"commit": {"committer": {"name": WHO[i], "date": iso(age)}, "author": {"name": WHO[i], "date": iso(age)}}})
|
||||
self.send_error(404)
|
||||
|
||||
|
||||
# --- VNC: a static "desktop" ---------------------------------------------------------------
|
||||
|
||||
W, H = 1280, 800
|
||||
|
||||
|
||||
def desktop():
|
||||
px = bytearray(W * H * 4)
|
||||
wins = [(90, 90, 620, 420, (40, 44, 52)), (560, 260, 1180, 700, (250, 250, 252)), (140, 560, 460, 740, (32, 140, 120))]
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
# Diagonal violet → teal wallpaper.
|
||||
k = (x + y) / (W + H)
|
||||
r, g, b = int(88 * (1 - k) + 20 * k), int(40 * (1 - k) + 150 * k), int(160 * (1 - k) + 170 * k)
|
||||
for x0, y0, x1, y1, c in wins:
|
||||
if x0 <= x < x1 and y0 <= y < y1:
|
||||
r, g, b = (200, 70, 90) if y < y0 + 28 else c
|
||||
if y >= y0 + 28 and c == (40, 44, 52) and (y - y0) % 22 < 9 and x0 + 20 < x < x0 + 20 + ((y * 37) % 400):
|
||||
r, g, b = (120, 220, 140) if (y // 22) % 3 else (230, 200, 120)
|
||||
if y >= H - 40:
|
||||
r, g, b = 24, 24, 30
|
||||
i = (y * W + x) * 4
|
||||
px[i:i + 4] = bytes((r, g, b, 0))
|
||||
return bytes(px)
|
||||
|
||||
|
||||
PIXELS = None
|
||||
|
||||
|
||||
class Vnc(socketserver.BaseRequestHandler):
|
||||
def recv(self, n):
|
||||
b = b""
|
||||
while len(b) < n:
|
||||
c = self.request.recv(n - len(b))
|
||||
if not c:
|
||||
raise EOFError
|
||||
b += c
|
||||
return b
|
||||
|
||||
def handle(self):
|
||||
s = self.request
|
||||
s.sendall(b"RFB 003.008\n")
|
||||
self.recv(12)
|
||||
s.sendall(bytes([1, 1])) # one security type: None
|
||||
self.recv(1)
|
||||
s.sendall(struct.pack(">I", 0))
|
||||
self.recv(1) # ClientInit
|
||||
name = b"studio:0 (demo)"
|
||||
s.sendall(struct.pack(">HH", W, H) + bytes([32, 24, 0, 1, 0, 255, 0, 255, 0, 255, 0, 8, 16, 0, 0, 0]) + struct.pack(">I", len(name)) + name)
|
||||
sent = False
|
||||
try:
|
||||
while True:
|
||||
t = self.recv(1)[0]
|
||||
if t == 0:
|
||||
self.recv(19)
|
||||
elif t == 2:
|
||||
n = struct.unpack(">xH", self.recv(3))[0]
|
||||
self.recv(4 * n)
|
||||
elif t == 3:
|
||||
self.recv(9)
|
||||
if not sent:
|
||||
s.sendall(struct.pack(">BxH", 0, 1) + struct.pack(">HHHHi", 0, 0, W, H, 0) + PIXELS)
|
||||
sent = True
|
||||
else:
|
||||
time.sleep(0.5)
|
||||
s.sendall(struct.pack(">BxH", 0, 0))
|
||||
elif t == 4:
|
||||
self.recv(7)
|
||||
elif t == 5:
|
||||
self.recv(5)
|
||||
elif t == 6:
|
||||
n = struct.unpack(">3xI", self.recv(7))[0]
|
||||
self.recv(n)
|
||||
else:
|
||||
return
|
||||
except (EOFError, ConnectionError):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
PIXELS = desktop()
|
||||
for port, handler in [(42023, Raw), (42667, Irc), (42900, Vnc)]:
|
||||
threading.Thread(target=Threaded(("127.0.0.1", port), handler).serve_forever, daemon=True).start()
|
||||
print("fakes ready", flush=True)
|
||||
ThreadingHTTPServer(("127.0.0.1", 42080), Github).serve_forever()
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates docs/guide/img/*.png: a throwaway sshd (ForceCommand, never a login shell),
|
||||
# fake RAW/IRC/GitHub/VNC servers, a demo data directory, then one headless app run per shot.
|
||||
# Needs: xvfb-run, sshd, ssh-keygen, python3, ImageMagick. Usage: docs/guide/tools/screenshots.sh [shot...]
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
GUIDE=$PWD
|
||||
B=${NEXUS_GUIDE_TMP:-/tmp/nexus-guide}
|
||||
IMG=$GUIDE/img
|
||||
APP=$GUIDE/../../rust/target/debug/nexus-terminal
|
||||
SCALE=${SCALE:-2}
|
||||
mkdir -p "$IMG"
|
||||
rm -rf "$B" && mkdir -p "$B/home/.ssh" "$B/sshd"
|
||||
|
||||
(cd ../../rust && cargo build -q -p nexus && cargo build -q -p nexus-core --example demo_data)
|
||||
|
||||
# Demo home: what the SFTP browser, the shell and the SSH key manager see.
|
||||
H=$B/home
|
||||
mkdir -p "$H"/{projects/nexus,backups,logs,www/static}
|
||||
printf 'server {\n listen 443 ssl;\n}\n' > "$H/nginx.conf"
|
||||
echo '#!/bin/sh' > "$H/deploy.sh"; chmod +x "$H/deploy.sh"
|
||||
head -c 180000 /dev/urandom > "$H/backups/db-2026-10-10.sql.gz"
|
||||
head -c 52000 /dev/urandom > "$H/logs/access.log"
|
||||
echo "# Notes" > "$H/README.md"
|
||||
ssh-keygen -q -t ed25519 -N '' -C 'andy@laptop' -f "$H/.ssh/id_ed25519"
|
||||
ssh-keygen -q -t rsa -b 2048 -N '' -C 'andy@work' -f "$H/.ssh/work_rsa"
|
||||
ssh-keygen -q -t ecdsa -N '' -C 'deploy-bot' -f "$H/.ssh/deploy_ecdsa"
|
||||
|
||||
# sshd on 42022 (ForceCommand keeps it away from ~/.profile).
|
||||
S=$B/sshd
|
||||
ssh-keygen -q -t ed25519 -N '' -f "$S/host"
|
||||
cp "$H/.ssh/id_ed25519.pub" "$S/authorized_keys"
|
||||
SFTP=$(ls /usr/lib/openssh/sftp-server /usr/libexec/openssh/sftp-server /usr/libexec/sftp-server 2>/dev/null | head -1 || true)
|
||||
cat > "$S/config" <<CFG
|
||||
Port 42022
|
||||
ListenAddress 127.0.0.1
|
||||
HostKey $S/host
|
||||
AuthorizedKeysFile $S/authorized_keys
|
||||
PidFile $S/pid
|
||||
StrictModes no
|
||||
UsePAM no
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
PrintMotd no
|
||||
PrintLastLog no
|
||||
Subsystem sftp $SFTP
|
||||
ForceCommand $GUIDE/tools/demo-shell.sh $H $SFTP
|
||||
CFG
|
||||
"$(command -v sshd)" -f "$S/config" -E "$S/log"
|
||||
python3 -I "$GUIDE/tools/fakes.py" > "$B/fakes.log" 2>&1 &
|
||||
FAKES=$!
|
||||
trap 'kill $FAKES 2>/dev/null; kill "$(cat "$S/pid" 2>/dev/null)" 2>/dev/null || true' EXIT
|
||||
until grep -q ready "$B/fakes.log"; do sleep 0.2; done
|
||||
|
||||
"$(dirname "$APP")/examples/demo_data" "$B/data" "$H/.ssh/id_ed25519" > /dev/null
|
||||
cp -r "$B/data" "$B/data.clean"
|
||||
|
||||
shot() { # name, then app args
|
||||
local name=$1; shift
|
||||
if [ ${#ONLY[@]} -gt 0 ] && [[ ! " ${ONLY[*]} " =~ " $name " ]]; then return 0; fi
|
||||
rm -rf "$B/data" && cp -r "$B/data.clean" "$B/data"
|
||||
HOME=$H WINIT_X11_SCALE_FACTOR=$SCALE timeout 60 xvfb-run -a -s "-screen 0 3200x2000x24" \
|
||||
"$APP" --data-dir "$B/data" --size 1360x860 --screenshot "$IMG/$name.png" "$@" 2>> "$B/app.log" || echo " ! $name failed"
|
||||
# 256-colour PNGs are a quarter of the size and look identical for UI shots.
|
||||
[ -f "$IMG/$name.png" ] && magick "$IMG/$name.png" -colors 256 "PNG8:$IMG/$name.png"
|
||||
echo " $name"
|
||||
}
|
||||
ONLY=("$@")
|
||||
|
||||
shot welcome
|
||||
shot welcome-midnight --theme midnight
|
||||
shot welcome-light --theme light
|
||||
shot ssh --connect "Sensor feed" --connect "Libera Chat" --connect atlas --shot-delay 5500
|
||||
shot ssh-light --theme light --connect atlas --shot-delay 5500
|
||||
shot hostkey --connect hello-ssh --shot-delay 5000
|
||||
shot split --connect atlas --do "Split Pane Right" --shot-delay 5500
|
||||
shot sftp --connect atlas --do "SFTP Browser" --shot-delay 6000
|
||||
shot broadcast --connect build-runner --connect atlas --do "Toggle Broadcast Mode" --shot-delay 5500
|
||||
shot raw --connect "Sensor feed" --shot-delay 5500
|
||||
shot irc --connect "Libera Chat" --shot-delay 5500
|
||||
shot github --connect GitHub --answer ghp_demo --shot-delay 6000
|
||||
shot vnc --connect "Studio desktop" --answer demo --shot-delay 6000
|
||||
shot local --run-local 'printf "\e[1;35mhello from the local shell\e[0m\n"; ls -la --color=always' --shot-delay 5000
|
||||
shot palette --open palette
|
||||
shot quick --open quick
|
||||
shot editor-ssh --open editor:ssh
|
||||
shot editor-new --open editor:new
|
||||
shot editor-serial --open editor:serial
|
||||
shot editor-telnet --open editor:telnet
|
||||
shot editor-raw --open editor:raw
|
||||
shot editor-vnc --open editor:vnc
|
||||
shot editor-irc --open editor:irc
|
||||
shot editor-github --open editor:github
|
||||
for t in general terminal security knownhosts help about; do shot "settings-$t" --open "settings:$t"; done
|
||||
shot keys --open keys
|
||||
shot snippets --open snippets
|
||||
shot triggers --open triggers
|
||||
shot recordings --open recordings
|
||||
shot export --open export
|
||||
Reference in New Issue
Block a user