update docs

This commit is contained in:
2026-09-22 13:23:34 +08:00
parent 99bc3d15c5
commit 8a4788fca8
126 changed files with 7198 additions and 2425 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "fds-control"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Native grayscale X11 cartridge control panel"
[dependencies]
clap.workspace = true
fds-common = { path = "../fds-common" }
libc = "0.2"
serde_json = "1"
x11rb = "=0.13.2"
+859
View File
@@ -0,0 +1,859 @@
//! Core X11 drawing keeps the control panel small, static and free of animation.
use clap::Parser;
use fds_common::{
Bay,
control::{self, BayState, Request, Response},
manifest::Class,
};
use std::{
error::Error,
io::{Read, Write},
os::{fd::AsRawFd, unix::net::UnixStream},
process::{Child, Command},
sync::mpsc,
time::{Duration, Instant},
};
use x11rb::{
COPY_DEPTH_FROM_PARENT,
connection::Connection,
protocol::{Event, xproto::*},
rust_connection::RustConnection,
wrapper::ConnectionExt as _,
};
type Result<T> = std::result::Result<T, Box<dyn Error>>;
#[derive(Parser)]
#[command(
version,
about = "FDS cartridge control panel for X11",
after_help = "Select a bay to inspect its cartridge. Run opens a terminal; Eject stops its programs and releases the cartridge. Remove media only after SAFE appears."
)]
struct Options {
/// X display; defaults to DISPLAY.
#[arg(long)]
display: Option<String>,
}
#[derive(Clone, Copy, PartialEq)]
enum Focus {
Bays,
Programs,
Run,
Eject,
Rescan,
}
impl Focus {
fn next(self) -> Self {
match self {
Self::Bays => Self::Programs,
Self::Programs => Self::Run,
Self::Run => Self::Eject,
Self::Eject => Self::Rescan,
Self::Rescan => Self::Bays,
}
}
}
#[derive(Clone)]
struct Program {
label: String,
alias: String,
}
struct Model {
bays: Vec<BayState>,
bay: usize,
program: usize,
focus: Focus,
status: String,
busy: bool,
available: bool,
display: String,
}
impl Model {
fn new() -> Self {
Self {
bays: Vec::new(),
bay: 0,
program: 0,
focus: Focus::Bays,
status: "Connecting to the cartridge service...".into(),
busy: false,
available: false,
display: std::env::var("DISPLAY").unwrap_or_else(|_| ":0".into()),
}
}
fn selected(&self) -> Option<&BayState> {
self.bays
.iter()
.find(|b| u8::from(b.bay) as usize == self.bay + 1)
}
fn programs(&self) -> Vec<Program> {
let Some(bay) = self.selected() else {
return Vec::new();
};
if bay.state != "mounted_read_only" {
return Vec::new();
}
bay.commands
.iter()
.map(|command| Program {
label: command.selector.clone(),
alias: command.alias.clone(),
})
.collect()
}
fn can_eject(&self) -> bool {
self.available
&& !self.busy
&& self
.selected()
.is_some_and(|b| b.mount.is_some() && b.state != "protected")
}
fn can_use_data(&self) -> bool {
self.available
&& !self.busy
&& self.selected().is_some_and(|b| {
b.state == "mounted_read_only"
&& b.manifest
.as_ref()
.is_some_and(|m| m.cartridge.class == Class::Data)
})
}
fn can_run(&self) -> bool {
self.available && !self.busy && self.program < self.programs().len()
}
fn select(&mut self, bay: usize) {
if self.bay != bay {
self.bay = bay;
self.program = 0;
}
}
}
// All labels are bounded before core X11 text requests. Cartridge names never
// become shell source; launches use an argument vector and the validated alias.
fn text_bytes(text: &str, max: usize) -> Vec<u8> {
let mut bytes: Vec<_> = text
.chars()
.map(|c| {
if c.is_ascii() && !c.is_control() {
c as u8
} else {
b'?'
}
})
.take(max + 1)
.collect();
if bytes.len() > max {
bytes.truncate(max);
if max >= 3 {
bytes[max - 3..].copy_from_slice(b"...");
}
}
bytes
}
fn state_label(state: &str) -> String {
state.replace('_', " ").to_uppercase()
}
struct View {
connection: RustConnection,
window: Window,
gc: Gcontext,
black: u32,
white: u32,
gray: u32,
delete: Atom,
protocols: Atom,
state_atom: Atom,
}
impl View {
fn new(display: Option<&str>) -> Result<Self> {
let (connection, index) = x11rb::connect(display)?;
let screen = &connection.setup().roots[index];
let (black, white) = (screen.black_pixel, screen.white_pixel);
let gray = connection
.alloc_color(screen.default_colormap, 0xcccc, 0xcccc, 0xcccc)?
.reply()?
.pixel;
let window = connection.generate_id()?;
connection
.create_window(
COPY_DEPTH_FROM_PARENT,
window,
screen.root,
30,
30,
900,
630,
2,
WindowClass::INPUT_OUTPUT,
0,
&CreateWindowAux::new()
.background_pixel(white)
.border_pixel(black)
.event_mask(
EventMask::EXPOSURE
| EventMask::BUTTON_PRESS
| EventMask::KEY_PRESS
| EventMask::STRUCTURE_NOTIFY,
),
)?
.check()?;
connection.change_property8(
PropMode::REPLACE,
window,
AtomEnum::WM_NAME,
AtomEnum::STRING,
b"FDS Control",
)?;
connection.change_property8(
PropMode::REPLACE,
window,
AtomEnum::WM_CLASS,
AtomEnum::STRING,
b"fds-control\0FdsControl\0",
)?;
let protocols = connection
.intern_atom(false, b"WM_PROTOCOLS")?
.reply()?
.atom;
let delete = connection
.intern_atom(false, b"WM_DELETE_WINDOW")?
.reply()?
.atom;
connection.change_property32(
PropMode::REPLACE,
window,
protocols,
AtomEnum::ATOM,
&[delete],
)?;
let state_atom = connection
.intern_atom(false, b"_FDS_CONTROL_STATE")?
.reply()?
.atom;
// Fixed dimensions keep the bitmap-font layout readable and predictable.
let mut hints = x11rb::properties::WmSizeHints::new();
hints.min_size = Some((900, 630));
hints.max_size = Some((900, 630));
hints.set_normal_hints(&connection, window)?;
let font = connection.generate_id()?;
if connection
.open_font(
font,
b"-*-terminus-medium-r-normal--16-*-*-*-*-*-iso10646-1",
)?
.check()
.is_err()
{
connection.open_font(font, b"fixed")?.check()?;
}
let gc = connection.generate_id()?;
connection.create_gc(
gc,
window,
&CreateGCAux::new()
.foreground(black)
.background(white)
.font(font)
.graphics_exposures(0),
)?;
connection.map_window(window)?;
connection.flush()?;
Ok(Self {
connection,
window,
gc,
black,
white,
gray,
delete,
protocols,
state_atom,
})
}
fn fill(&self, x: i16, y: i16, width: u16, height: u16, color: u32) -> Result<()> {
self.connection
.change_gc(self.gc, &ChangeGCAux::new().foreground(color))?;
self.connection.poly_fill_rectangle(
self.window,
self.gc,
&[Rectangle {
x,
y,
width,
height,
}],
)?;
Ok(())
}
fn border(&self, x: i16, y: i16, width: u16, height: u16) -> Result<()> {
self.connection
.change_gc(self.gc, &ChangeGCAux::new().foreground(self.black))?;
self.connection.poly_rectangle(
self.window,
self.gc,
&[Rectangle {
x,
y,
width,
height,
}],
)?;
Ok(())
}
fn text(&self, x: i16, y: i16, text: &str, max: usize, inverted: bool) -> Result<()> {
self.connection.change_gc(
self.gc,
&ChangeGCAux::new().foreground(if inverted { self.white } else { self.black }),
)?;
let bytes = text_bytes(text, max.min(254));
// poly_text draws only glyphs, preserving the selection/background fill.
let mut data = vec![bytes.len() as u8, 0];
data.extend(bytes);
self.connection
.poly_text8(self.window, self.gc, x, y, &data)?;
Ok(())
}
fn button(&self, x: i16, width: u16, label: &str, enabled: bool, focused: bool) -> Result<()> {
self.fill(
x,
498,
width,
38,
if enabled && focused {
self.black
} else if enabled {
self.white
} else {
self.gray
},
)?;
self.border(x, 498, width, 38)?;
self.text(
x + 12,
522,
label,
(width as usize - 24) / 8,
enabled && focused,
)?;
Ok(())
}
fn draw(&self, model: &Model) -> Result<()> {
// Expose the same visible state to X11 inspection/accessibility tools.
let state = serde_json::json!({"bay": model.bay + 1, "state": model.selected().map(|b| &b.state), "busy": model.busy, "available": model.available, "commands": model.programs().len(), "status": model.status});
self.connection.change_property8(
PropMode::REPLACE,
self.window,
self.state_atom,
AtomEnum::STRING,
serde_json::to_string(&state)?.as_bytes(),
)?;
self.fill(0, 0, 900, 630, self.white)?;
self.fill(0, 0, 900, 72, self.black)?;
self.text(22, 29, "FDS / CONTROL", 50, true)?;
self.text(22, 53, "Cartridges and programs", 70, true)?;
self.text(710, 42, "TWELVE BAYS", 22, true)?;
self.text(22, 98, "BAY CARTRIDGE / STATE", 37, false)?;
for index in 0..12 {
let y = 110 + index as i16 * 31;
let selected = model.bay == index;
self.fill(
20,
y,
300,
31,
if selected { self.black } else { self.white },
)?;
self.border(20, y, 300, 31)?;
let bay = model
.bays
.iter()
.find(|b| u8::from(b.bay) as usize == index + 1);
let label = match bay {
Some(b) => {
if b.state == "empty" {
"Empty".into()
} else if b.state == "safe" {
"SAFE - remove cartridge".into()
} else {
b.name.clone().unwrap_or_else(|| state_label(&b.state))
}
}
None => "Unavailable".into(),
};
self.text(
30,
y + 21,
&format!("{:02} {label}", index + 1),
35,
selected,
)?;
}
if model.focus == Focus::Bays {
self.border(17, 107, 306, 378)?;
}
self.text(346, 98, &format!("BAY {:02}", model.bay + 1), 60, false)?;
if let Some(bay) = model.selected() {
self.text(
346,
130,
bay.name.as_deref().unwrap_or("No cartridge"),
66,
false,
)?;
self.text(346, 156, &state_label(&bay.state), 66, false)?;
if let Some(manifest) = &bay.manifest {
self.text(
346,
184,
&format!("Type: {:?}", manifest.cartridge.class),
66,
false,
)?;
self.text(
346,
207,
&format!("ID: {}", manifest.cartridge.id),
66,
false,
)?;
}
self.text(
346,
235,
&format!("Running processes: {}", bay.consumers),
66,
false,
)?;
if let Some(detail) = &bay.detail {
self.text(346, 260, detail, 66, false)?;
}
} else {
self.text(346, 132, "Waiting for the cartridge service.", 66, false)?;
}
self.text(346, 292, "PROGRAMS", 66, false)?;
let programs = model.programs();
let first = model.program.saturating_sub(4);
for row in 0..5 {
let index = first + row;
let y = 304 + row as i16 * 31;
let selected = index == model.program && index < programs.len();
self.fill(
346,
y,
532,
31,
if selected { self.black } else { self.white },
)?;
self.border(346, y, 532, 31)?;
if let Some(program) = programs.get(index) {
self.text(356, y + 21, &program.label, 64, selected)?;
} else if row == 0 {
self.text(
356,
y + 21,
"No software commands on this cartridge",
64,
false,
)?;
}
}
if model.focus == Focus::Programs {
self.border(343, 301, 538, 161)?;
}
if !programs.is_empty() {
self.text(
346,
482,
&format!("Command {} of {}", model.program + 1, programs.len()),
66,
false,
)?;
}
self.button(
346,
192,
if model.can_use_data() {
"Use DATA"
} else {
"Run in terminal"
},
model.can_run() || model.can_use_data(),
model.focus == Focus::Run,
)?;
self.button(
554,
148,
"Safe eject",
model.can_eject(),
model.focus == Focus::Eject,
)?;
self.button(
718,
160,
"Rescan",
!model.busy,
model.focus == Focus::Rescan,
)?;
self.text(22, 517, "Remove media only after SAFE.", 37, false)?;
self.fill(20, 552, 858, 44, self.gray)?;
self.text(30, 572, &model.status, 104, false)?;
self.text(
30,
589,
&model.status.chars().skip(104).collect::<String>(),
104,
false,
)?;
self.text(
22,
619,
"Arrows: select Tab: focus Enter: activate R: rescan Esc: close",
106,
false,
)?;
self.connection.flush()?;
Ok(())
}
fn keysym(&self, keycode: u8) -> Result<u32> {
let mapping = self.connection.get_keyboard_mapping(keycode, 1)?.reply()?;
Ok(mapping.keysyms.first().copied().unwrap_or(0))
}
}
struct Update {
result: fds_common::Result<Response>,
action: Option<String>,
}
struct Worker {
request: mpsc::SyncSender<(Request, Option<String>)>,
response: mpsc::Receiver<Update>,
wake: UnixStream,
in_flight: bool,
queued: Option<(Request, Option<String>)>,
}
impl Worker {
fn new() -> Result<Self> {
let (tx, rx) = mpsc::sync_channel::<(Request, Option<String>)>(1);
let (updates, response) = mpsc::channel();
let (wake, mut writer) = UnixStream::pair()?;
wake.set_nonblocking(true)?;
std::thread::spawn(move || {
while let Ok((request, action)) = rx.recv() {
let result = control::request(&request).and_then(|reply| {
let inventory = if matches!(request, Request::Bays) {
reply
} else {
control::request(&Request::Bays)?
};
if let Request::Eject { bay } = &request {
if !inventory.bays.iter().any(|b| b.bay == *bay && matches!(b.state.as_str(), "safe" | "empty")) {
return Err(fds_common::Error("Bay changed after eject; inspect its current state before removing media".into()));
}
}
Ok(inventory)
});
if updates.send(Update { result, action }).is_err()
|| writer.write_all(b"R").is_err()
{
break;
}
}
});
Ok(Self {
request: tx,
response,
wake,
in_flight: false,
queued: None,
})
}
fn send(&mut self, model: &mut Model, request: Request, action: Option<String>) -> Result<()> {
if self.in_flight {
// One user operation may queue behind the background status read.
// Polling never disables controls or discards a click.
if action.is_some() && self.queued.is_none() {
self.queued = Some((request, action));
model.busy = true;
}
} else {
model.busy = action.is_some();
self.request.send((request, action))?;
self.in_flight = true;
}
Ok(())
}
}
fn activate(
focus: Focus,
model: &mut Model,
worker: &mut Worker,
children: &mut Vec<Child>,
) -> Result<()> {
match focus {
Focus::Run if model.can_use_data() => {
model.status = "Activating DATA...".into();
worker.send(
model,
Request::DataUse {
bay: Bay::try_from((model.bay + 1) as u8)?,
},
Some("DATA is active at /data.".into()),
)?;
}
Focus::Programs | Focus::Run if model.can_run() => {
let program = &model.programs()[model.program];
match Command::new("/usr/bin/xterm")
.env("DISPLAY", &model.display)
.args([
"-hold",
"-T",
&program.label,
"-fa",
"Terminus",
"-fs",
"16",
"-bg",
"white",
"-fg",
"black",
"-e",
"/usr/bin/fds-program",
&program.alias,
])
.spawn()
{
Ok(child) => {
children.push(child);
model.status = format!(
"Opened {}. Close its terminal when finished.",
program.label
);
}
Err(error) => model.status = format!("Cannot open terminal: {error}"),
}
}
Focus::Eject if model.can_eject() => {
model.status = format!(
"Releasing bay {:02}; waiting for programs and storage...",
model.bay + 1
);
worker.send(
model,
Request::Eject {
bay: Bay::try_from((model.bay + 1) as u8)?,
},
Some(format!(
"Bay {:02} is SAFE. You may remove the cartridge.",
model.bay + 1
)),
)?;
}
Focus::Rescan if !model.busy => {
model.status = "Scanning cartridge bays...".into();
worker.send(
model,
Request::Rescan,
Some("Cartridge inventory refreshed.".into()),
)?;
}
_ => (),
}
Ok(())
}
fn run() -> Result<()> {
let options = Options::parse();
let view = View::new(options.display.as_deref())?;
let mut model = Model::new();
if let Some(display) = options.display {
model.display = display;
}
let mut worker = Worker::new()?;
let mut children: Vec<Child> = Vec::new();
worker.send(
&mut model,
Request::Bays,
Some("Select a bay to inspect its cartridge.".into()),
)?;
let mut refresh = Instant::now() + Duration::from_secs(2);
let mut previous = String::new();
let mut redraw = true;
let mut painted_busy = false;
loop {
while let Some(event) = view.connection.poll_for_event()? {
match event {
Event::Expose(_) => redraw = true,
Event::ClientMessage(e)
if e.type_ == view.protocols && e.data.as_data32()[0] == view.delete =>
{
return Ok(());
}
Event::DestroyNotify(_) => return Ok(()),
Event::Error(e) => return Err(format!("X11 protocol error: {e:?}").into()),
Event::ButtonPress(e) if e.detail == 1 => {
let (x, y) = (e.event_x, e.event_y);
if (20..320).contains(&x) && (110..482).contains(&y) {
model.select(((y - 110) / 31) as usize);
model.focus = Focus::Bays;
} else if (346..878).contains(&x) && (304..459).contains(&y) {
let index = model.program.saturating_sub(4) + ((y - 304) / 31) as usize;
if index < model.programs().len() {
model.program = index;
model.focus = Focus::Programs;
}
} else if (498..536).contains(&y) {
let focus = if (346..538).contains(&x) {
Some(Focus::Run)
} else if (554..702).contains(&x) {
Some(Focus::Eject)
} else if (718..878).contains(&x) {
Some(Focus::Rescan)
} else {
None
};
if let Some(focus) = focus {
model.focus = focus;
activate(focus, &mut model, &mut worker, &mut children)?;
}
}
redraw = true;
}
Event::KeyPress(e) => {
match view.keysym(e.detail)? {
0xff1b => return Ok(()),
0xff09 => model.focus = model.focus.next(),
0xff52 | 0xff54 => {
let down = view.keysym(e.detail)? == 0xff54;
if model.focus == Focus::Programs {
let length = model.programs().len();
if length > 0 {
model.program = if down {
(model.program + 1).min(length - 1)
} else {
model.program.saturating_sub(1)
};
}
} else {
model.select(if down {
(model.bay + 1).min(11)
} else {
model.bay.saturating_sub(1)
});
}
}
0xff0d => activate(model.focus, &mut model, &mut worker, &mut children)?,
0x72 | 0x52 => {
activate(Focus::Rescan, &mut model, &mut worker, &mut children)?
}
_ => (),
}
redraw = true;
}
_ => (),
}
}
while let Ok(update) = worker.response.try_recv() {
worker.in_flight = false;
if update.action.is_some() {
model.busy = false;
}
redraw |= painted_busy;
match update.result {
Ok(reply) => {
let serialized = serde_json::to_string(&reply.bays)?;
let changed = serialized != previous;
let reconnected = !model.available;
model.available = true;
model.bays = reply.bays;
if model.program >= model.programs().len() {
model.program = 0;
}
if let Some(action) = update.action {
model.status = action;
redraw = true;
} else if changed && !reconnected && !model.busy {
model.status =
"Cartridge inventory updated. Check the selected bay's current state."
.into();
redraw = true;
} else if reconnected {
model.status = "Cartridge service connected.".into();
redraw = true;
}
previous = serialized;
redraw |= changed;
}
Err(error) => {
let status = format!("Operation failed: {error}");
redraw |= status != model.status;
model.status = status;
model.available = false;
}
}
if let Some((request, action)) = worker.queued.take() {
worker.send(&mut model, request, action)?;
}
refresh = Instant::now() + Duration::from_secs(2);
}
children.retain_mut(|child| !matches!(child.try_wait(), Ok(Some(_))));
if redraw {
view.draw(&model)?;
painted_busy = model.busy;
redraw = false;
}
if Instant::now() >= refresh {
worker.send(&mut model, Request::Bays, None)?;
refresh = Instant::now() + Duration::from_secs(2);
}
let mut fds = [
libc::pollfd {
fd: view.connection.stream().as_raw_fd(),
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: worker.wake.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
},
];
let remaining = refresh
.saturating_duration_since(Instant::now())
.as_millis()
.min(2000) as i32;
if unsafe { libc::poll(fds.as_mut_ptr(), 2, remaining) } < 0
&& std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
{
return Err(std::io::Error::last_os_error().into());
}
if fds[1].revents != 0 {
let mut buffer = [0; 64];
let _ = worker.wake.read(&mut buffer);
}
}
}
fn main() {
if let Err(error) = run() {
eprintln!("fds-control: {error}");
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cartridge_labels_are_bounded_and_cannot_inject_x11_text_items() {
assert_eq!(text_bytes("abc\n\u{ff}def", 20), b"abc??def");
assert_eq!(text_bytes("abcdefgh", 6), b"abc...");
assert_eq!(text_bytes("", 10), b"");
}
#[test]
fn disconnected_controls_do_not_allow_operations() {
let model = Model::new();
assert!(!model.can_eject());
assert!(!model.can_run());
use clap::CommandFactory;
Options::command().debug_assert();
}
}