Desktop GUI, cross-platform reader detection

- nfc-writer is now a native egui window (Windows, macOS, Linux); the web
  version moves to the nfc-writer-web binary. Both share src/reader.rs and
  src/ndef.rs through the library crate.
- Reader choice no longer depends on the ACR1252U's Windows naming: skip SAM
  slots, prefer contactless names (PICC/Contactless/NFC/CL), then a reader
  with a card present; NFC_READER forces one by name substring.
- ATR via SCardStatus instead of SCardGetAttrib, which macOS lacks.
- Platform-specific "no reader" hints (pcscd/libccid on Linux, ACS driver on
  macOS); Thai fallback font for the GUI from the system fonts.
- Clippy clean for x86_64-pc-windows-msvc, x86_64-unknown-linux-gnu and
  aarch64-apple-darwin; README with per-OS setup and a test matrix.
This commit is contained in:
grabowski committed 2026-10-29 09:30:31 +07:00
1 parent 449cdb7af5
commit cb1b1b04c8
7 files changed
+4820 -111

No files matched your search

+267 -63
View File
@@ -1,78 +1,282 @@
mod ndef;
mod reader;
//! Desktop GUI: same reader logic as the web version, in a native window (Windows, macOS, Linux).
use axum::{
Json, Router,
extract::State,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use serde::Deserialize;
use serde_json::json;
use std::sync::{Arc, Mutex};
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
const INDEX: &str = include_str!("../static/index.html");
use eframe::egui::{self, Color32, RichText};
use nfc_writer::reader::{self, Cache, Programmed, Status, TagInfo};
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
/// One reader, one operation at a time: the mutex serialises status polls and writes.
type Shared = Arc<Mutex<reader::Cache>>;
const POLL: Duration = Duration::from_millis(500);
const HINT: &str = "Links (https://, www., tel:, mailto:, geo: …) are written as a link, anything else as plain text.";
#[tokio::main]
async fn main() {
let port: u16 = std::env::var("NFC_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(7252);
let app = Router::new()
.route("/", get(|| async { Html(INDEX) }))
.route("/api/status", get(status))
.route("/api/program", post(program))
.with_state(Shared::default());
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.unwrap_or_else(|e| panic!("bind {addr}: {e}"));
println!("NFC writer running on http://{addr}");
axum::serve(listener, app).await.expect("server error");
#[derive(Default)]
struct Shared {
status: Option<Status>,
busy: bool,
result: Option<Result<Programmed, String>>,
}
async fn with_reader<T: Send + 'static>(
s: Shared,
f: impl FnOnce(&mut reader::Cache) -> T + Send + 'static,
) -> Result<T, String> {
tokio::task::spawn_blocking(move || {
let mut cache = s.lock().unwrap_or_else(|e| e.into_inner());
f(&mut cache)
})
.await
.map_err(|e| format!("internal error: {e}"))
struct App {
text: String,
shared: Arc<Mutex<Shared>>,
jobs: mpsc::Sender<String>,
}
async fn status(State(s): State<Shared>) -> Json<reader::Status> {
Json(
with_reader(s, reader::status)
.await
.unwrap_or_else(|message| reader::Status::Error { message }),
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("NFC tag writer")
.with_inner_size([540.0, 330.0])
.with_min_inner_size([400.0, 300.0]),
..Default::default()
};
eframe::run_native(
"nfc-writer",
options,
Box::new(|cc| {
add_thai_font(&cc.egui_ctx);
Ok(Box::new(App::new(cc.egui_ctx.clone())))
}),
)
}
#[derive(Deserialize)]
struct ProgramRequest {
text: String,
/// egui's built-in fonts have no Thai glyphs: add the first system font that has them.
fn add_thai_font(ctx: &egui::Context) {
const CANDIDATES: [&str; 8] = [
r"C:\Windows\Fonts\LeelawUI.ttf",
r"C:\Windows\Fonts\tahoma.ttf",
"/System/Library/Fonts/Supplemental/Ayuthaya.ttf",
"/System/Library/Fonts/Supplemental/Tahoma.ttf",
"/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
"/usr/share/fonts/noto/NotoSansThai-Regular.ttf",
"/usr/share/fonts/google-noto/NotoSansThai-Regular.ttf",
"/usr/share/fonts/truetype/tlwg/Loma.ttf",
];
let Some(bytes) = CANDIDATES.iter().find_map(|p| std::fs::read(p).ok()) else {
return;
};
let mut fonts = egui::FontDefinitions::default();
fonts
.font_data
.insert("thai".into(), Arc::new(egui::FontData::from_owned(bytes)));
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
fonts
.families
.entry(family)
.or_default()
.push("thai".into());
}
ctx.set_fonts(fonts);
}
async fn program(State(s): State<Shared>, Json(req): Json<ProgramRequest>) -> Response {
match with_reader(s, move |c| reader::program(c, &req.text)).await {
Ok(Ok(done)) => Json(done).into_response(),
Ok(Err(error)) => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": error })),
)
.into_response(),
Err(error) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": error })),
)
.into_response(),
/// Owns the reader: polls the status and runs program jobs, one at a time.
fn worker(jobs: mpsc::Receiver<String>, shared: Arc<Mutex<Shared>>, ctx: egui::Context) {
let mut cache = Cache::default();
loop {
let job = match jobs.recv_timeout(POLL) {
Ok(text) => Some(text),
Err(mpsc::RecvTimeoutError::Timeout) => None,
Err(mpsc::RecvTimeoutError::Disconnected) => return,
};
let result = job.map(|text| reader::program(&mut cache, &text));
let status = reader::status(&mut cache);
let mut s = shared.lock().unwrap_or_else(|e| e.into_inner());
let changed = result.is_some() || s.status.as_ref() != Some(&status);
if let Some(r) = result {
s.result = Some(r);
s.busy = false;
}
s.status = Some(status);
drop(s);
if changed {
ctx.request_repaint();
}
}
}
impl App {
fn new(ctx: egui::Context) -> Self {
let shared = Arc::new(Mutex::new(Shared::default()));
let (jobs, rx) = mpsc::channel();
let s = shared.clone();
std::thread::spawn(move || worker(rx, s, ctx));
Self {
text: String::new(),
shared,
jobs,
}
}
fn program(&mut self) {
let mut s = self.shared.lock().unwrap_or_else(|e| e.into_inner());
if s.busy {
return;
}
s.busy = true;
s.result = None;
let _ = self.jobs.send(self.text.clone());
}
}
struct Palette {
ok: Color32,
warn: Color32,
err: Color32,
accent: Color32,
}
fn palette(dark: bool) -> Palette {
if dark {
Palette {
ok: Color32::from_rgb(0x22, 0xc5, 0x5e),
warn: Color32::from_rgb(0xfb, 0xbf, 0x24),
err: Color32::from_rgb(0xf8, 0x71, 0x71),
accent: Color32::from_rgb(0x3b, 0x82, 0xf6),
}
} else {
Palette {
ok: Color32::from_rgb(0x15, 0x80, 0x3d),
warn: Color32::from_rgb(0xb4, 0x53, 0x09),
err: Color32::from_rgb(0xb9, 0x1c, 0x1c),
accent: Color32::from_rgb(0x25, 0x63, 0xeb),
}
}
}
fn can_write(tag: &TagInfo) -> bool {
tag.formatted && tag.writable && tag.capacity.is_some() && tag.note.is_none()
}
fn status_line(ui: &mut egui::Ui, color: Color32, text: &str) {
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().circle_filled(rect.center(), 5.0, color);
ui.label(text);
});
});
}
fn tag_details(ui: &mut egui::Ui, tag: &TagInfo, p: &Palette) {
egui::Grid::new("tag")
.num_columns(2)
.spacing([14.0, 3.0])
.show(ui, |ui| {
ui.weak("UID");
ui.monospace(&tag.uid);
ui.end_row();
ui.weak("Type");
ui.label(&tag.kind);
ui.end_row();
ui.weak("Memory");
ui.label(match tag.capacity {
Some(cap) => format!(
"{} of {cap} bytes used{}",
tag.used.map_or("?".into(), |u| u.to_string()),
if tag.writable { "" } else { ", read-only" }
),
None => "—".into(),
});
ui.end_row();
ui.weak("Content");
ui.vertical(|ui| {
if let Some(note) = &tag.note {
ui.colored_label(p.warn, note);
} else if tag.records.is_empty() {
ui.label("empty");
} else {
for r in &tag.records {
ui.horizontal_wrapped(|ui| {
ui.label(format!("{}:", r.kind));
if r.kind == "URL" && r.value.starts_with("http") {
ui.hyperlink(&r.value);
} else {
ui.label(&r.value);
}
});
}
}
});
ui.end_row();
});
}
impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let (status, busy, result) = {
let s = self.shared.lock().unwrap_or_else(|e| e.into_inner());
(s.status.clone(), s.busy, s.result.clone())
};
egui::CentralPanel::default_margins().show(ui, |ui| {
let p = palette(ui.visuals().dark_mode);
ui.add_space(4.0);
ui.heading("NFC tag writer");
ui.add_space(8.0);
let mut writable = false;
match &status {
None => status_line(ui, ui.visuals().weak_text_color(), "Looking for a reader…"),
Some(Status::NoReader { message } | Status::Error { message }) => {
status_line(ui, p.err, message)
}
Some(Status::NoCard { reader }) => {
status_line(ui, p.warn, &format!("{reader} — place a tag on the reader"))
}
Some(Status::Card { reader, tag }) => {
writable = can_write(tag);
let color = if writable { p.ok } else { p.warn };
status_line(ui, color, &format!("{reader} — tag present"));
ui.add_space(4.0);
tag_details(ui, tag, &p);
}
}
ui.add_space(14.0);
let mut submit = false;
ui.horizontal(|ui| {
let button_width = 110.0;
let edit_width = ui.available_width() - button_width - ui.spacing().item_spacing.x;
let edit = ui.add_sized(
[edit_width, 30.0],
egui::TextEdit::singleline(&mut self.text)
.hint_text("https://buildfor.life or any text")
.align(egui::Align2::LEFT_CENTER),
);
if edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
submit = true;
edit.request_focus();
}
let label = if busy { "Programming…" } else { "Program" };
let button = egui::Button::new(RichText::new(label).strong().color(Color32::WHITE))
.fill(p.accent)
.min_size(egui::vec2(button_width, 30.0));
if ui.add_enabled(writable && !busy, button).clicked() {
submit = true;
}
});
ui.add_space(4.0);
ui.small(RichText::new(HINT).weak());
ui.add_space(8.0);
match &result {
Some(Ok(done)) => {
ui.colored_label(
p.ok,
format!(
"✔ Wrote {}: {} ({} of {} bytes, verified)",
done.record.kind, done.record.value, done.bytes, done.capacity
),
);
}
Some(Err(e)) => {
ui.colored_label(p.err, e);
}
None => {}
}
if submit && writable && !busy {
self.program();
}
});
}
}