Files
RedBear-OS/local/recipes/system/redbear-netctl-console/source/src/main.rs
T
2026-04-16 12:44:35 +01:00

273 lines
8.0 KiB
Rust

use std::ffi::CString;
use std::os::raw::{c_char, c_int, c_void};
use std::process;
use termion::event::Key;
use redbear_netctl_console::app::{App, Focus};
use redbear_netctl_console::backend::FsBackend;
#[link(name = "ncursesw")]
unsafe extern "C" {
static mut stdscr: *mut c_void;
fn initscr() -> *mut c_void;
fn raw() -> c_int;
fn noecho() -> c_int;
fn keypad(win: *mut c_void, bf: c_int) -> c_int;
fn curs_set(visibility: c_int) -> c_int;
fn endwin() -> c_int;
fn erase() -> c_int;
fn getch() -> c_int;
fn clrtoeol() -> c_int;
fn mvaddnstr(y: c_int, x: c_int, s: *const c_char, n: c_int) -> c_int;
fn refresh() -> c_int;
}
fn main() {
if let Err(err) = run() {
// SAFETY: best-effort terminal restore on failure path.
unsafe {
let _ = endwin();
}
eprintln!("redbear-netctl-console: {err}");
process::exit(1);
}
}
fn run() -> Result<(), String> {
if std::env::args()
.skip(1)
.any(|arg| matches!(arg.as_str(), "-h" | "--help" | "help"))
{
print_help();
return Ok(());
}
let backend = FsBackend::from_env();
let mut app = App::new(backend)?;
// SAFETY: ncurses global terminal initialization/teardown is process-global and called once.
unsafe {
let _ = initscr();
let _ = raw();
let _ = noecho();
let _ = keypad(stdscr, 1);
let _ = curs_set(0);
}
loop {
render(&app);
if app.should_quit {
break;
}
// SAFETY: ncurses is initialized above; getch reads one key event.
let ch = unsafe { getch() };
if let Some(key) = map_key(ch) {
app.handle_key(key);
}
}
// SAFETY: matching teardown for the ncurses session started above.
unsafe {
let _ = endwin();
}
Ok(())
}
fn map_key(ch: i32) -> Option<Key> {
match ch {
9 => Some(Key::Char('\t')),
10 | 13 => Some(Key::Char('\n')),
27 => Some(Key::Esc),
127 | 8 => Some(Key::Backspace),
c if (32..=126).contains(&c) => Some(Key::Char(c as u8 as char)),
_ => None,
}
}
fn render(app: &App<FsBackend>) {
// SAFETY: ncurses is initialized for the lifetime of the main loop.
unsafe {
let _ = erase();
}
let width = 120usize;
draw_line(
0,
&format!(
"Red Bear Netctl Console [ncurses] focus={} active={}{}",
focus_label(app.focus),
app.active_profile.as_deref().unwrap_or("<none>"),
if app.dirty { " dirty=*" } else { "" }
),
);
draw_line(1, &truncate(&app.message, width));
let mut line = 3;
draw_line(line, "Profiles");
line += 1;
if app.profiles.is_empty() {
draw_line(line, " <no saved profiles>");
line += 1;
} else {
for (idx, profile) in app.profiles.iter().enumerate().take(6) {
let marker = if idx == app.selected_profile && app.focus == Focus::Profiles {
'>'
} else {
' '
};
let active = if app.active_profile.as_deref() == Some(profile.as_str()) {
"*"
} else {
" "
};
draw_line(
line,
&truncate(&format!("{}{} {}", marker, active, profile), width),
);
line += 1;
}
}
line += 1;
draw_line(line, "Scan Results");
line += 1;
if app.scans.is_empty() {
draw_line(line, " <none; press r to scan>");
line += 1;
} else {
for (idx, scan) in app.scans.iter().enumerate().take(6) {
let marker = if idx == app.selected_scan && app.focus == Focus::Scan {
'>'
} else {
' '
};
draw_line(
line,
&truncate(&format!("{} {}", marker, scan.label()), width),
);
line += 1;
}
}
line += 1;
draw_line(line, "Profile Draft");
line += 1;
for field in app.visible_fields() {
let marker = if app.focus == Focus::Fields && app.selected_field() == field {
'>'
} else {
' '
};
draw_line(
line,
&truncate(
&format!(
"{} {:<12} {}",
marker,
field.label(),
app.field_value(field)
),
width,
),
);
line += 1;
}
if let Some(editor) = &app.editor {
line += 1;
draw_line(
line,
&truncate(
&format!("Editing {}: {}", editor.field.label(), editor.buffer),
width,
),
);
line += 1;
draw_line(line, "Enter saves. Esc cancels. Backspace deletes.");
line += 1;
}
line += 1;
draw_line(line, "Runtime Status");
line += 1;
for status_line in [
format!("iface={} addr={}", app.status.interface, app.status.address),
format!(
"status={} link={}",
app.status.status, app.status.link_state
),
format!(
"fw={} transport={} init={} activation={}",
app.status.firmware_status,
app.status.transport_status,
app.status.transport_init_status,
app.status.activation_status
),
format!("connect={}", app.status.connect_result),
format!("disconnect={}", app.status.disconnect_result),
format!("last_error={}", app.status.last_error),
] {
draw_line(line, &truncate(&status_line, width));
line += 1;
}
// SAFETY: drawing to stdscr is valid while ncurses session is active.
unsafe {
draw_raw_line(
28,
"Keys: Tab switch panes | Enter select/edit | h/l cycle | j/k move | r scan | s save | a activate | c connect | d disconnect | n new | q quit",
);
let _ = refresh();
}
}
fn draw_line(row: i32, text: &str) {
// SAFETY: drawing to stdscr is valid while ncurses session is active.
unsafe {
draw_raw_line(row, text);
}
}
fn truncate(value: &str, width: usize) -> String {
if value.chars().count() <= width {
return value.to_string();
}
value
.chars()
.take(width.saturating_sub(1))
.collect::<String>()
+ ""
}
fn focus_label(focus: Focus) -> &'static str {
match focus {
Focus::Profiles => "profiles",
Focus::Scan => "scan",
Focus::Fields => "fields",
}
}
unsafe fn move_add_line(row: i32, text: &str) {
let sanitized = text.replace('\0', "?");
if let Ok(cstr) = CString::new(sanitized) {
let _ = unsafe { mvaddnstr(row, 0, cstr.as_ptr(), i32::MAX) };
}
}
unsafe fn draw_raw_line(row: i32, text: &str) {
let blank = " ".repeat(140);
unsafe { move_add_line(row, &blank) };
unsafe { move_add_line(row, text) };
let _ = unsafe { clrtoeol() };
}
fn print_help() {
println!(
"Usage: redbear-netctl-console\n\nA ncurses-based console client for the bounded Red Bear Wi-Fi/netctl flow.\n\nKeys:\n Tab switch panes\n Enter load profile, apply scan result, or edit selected field\n h / l cycle Security or IP mode on the selected field\n j / k move selection down / up\n r scan with /scheme/wifictl\n s save draft to /etc/netctl/<profile>\n a save draft and write /etc/netctl/active\n c save + connect through the bounded wifictl/netctl flow\n d disconnect current interface and clear /etc/netctl/active when it matches\n n start a new Wi-Fi profile draft\n q quit\n\nEnvironment overrides match redbear-netctl tests:\n REDBEAR_NETCTL_PROFILE_DIR\n REDBEAR_NETCTL_ACTIVE\n REDBEAR_WIFICTL_ROOT\n REDBEAR_NETCFG_ROOT\n REDBEAR_DHCPD_CMD\n REDBEAR_DHCPD_WAIT_MS\n REDBEAR_DHCPD_POLL_MS"
);
}