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

Generated
+4239 -7
View File
File diff suppressed because it is too large. Load diff
+16 -2
View File
@@ -1,11 +1,25 @@
[package] [package]
name = "nfc-writer" name = "nfc-writer"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
description = "Local web page that programs NFC tags (NDEF URL/text) through a PC/SC reader such as the ACS ACR1252U" description = "Program NFC tags (NDEF URL/text) through a PC/SC reader such as the ACS ACR1252U: desktop GUI and local web page"
default-run = "nfc-writer"
[[bin]]
name = "nfc-writer"
path = "src/main.rs"
[[bin]]
name = "nfc-writer-web"
path = "src/bin/nfc-writer-web.rs"
[profile.release]
strip = true
lto = "thin"
[dependencies] [dependencies]
axum = "0.8" axum = "0.8"
eframe = "0.37"
pcsc = "2" pcsc = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+64 -9
View File
@@ -1,14 +1,69 @@
# nfc-writer # NFC tag writer
Small local web page that programs NFC tags through a PC/SC reader (tested with the ACS ACR1252U-M1). Programs NFC tags through a USB PC/SC reader such as the ACS ACR1252U-M1. Type a URL or some text, put a tag on the reader, press **Program**.
Type a URL or some text, put a tag on the reader, press **Program**.
There are two ways to run it, with the same reader logic behind both:
| Program | What it is |
|---|---|
| `nfc-writer` | Desktop window. No browser or network needed. |
| `nfc-writer-web` | Local web page at http://127.0.0.1:7252 (port: `NFC_PORT`), reachable from this machine only. |
## What it writes
- Input that starts with `http://`, `https://`, `www.`, `tel:`, `mailto:`, `sms:` or `geo:`, or any `scheme://`, becomes an NDEF link record. `www.` gets `https://` in front.
- Anything else becomes an NDEF text record (language `en`).
- Supported tags: NTAG213, NTAG215, NTAG216 and MIFARE Ultralight / EV1 / C (NFC Forum Type 2). MIFARE Classic, DESFire and bank cards are recognised and refused with a message.
- A write first empties the tag's NDEF message and writes its header last. If the tag is pulled away halfway, it ends up empty rather than holding broken data. After writing, the tag is read back and compared.
## Which reader it uses
The program asks the operating system's smart-card service (PC/SC) for readers. Any PC/SC contactless reader should work, not only the ACR1252U.
- It skips SAM slots (the ACR1252U shows up as two readers, `… PICC` and `… SAM`).
- It prefers a reader whose name says contactless (`PICC`, `Contactless`, `NFC`, `CL`), then a reader that has a card on it.
- To force a reader, set `NFC_READER` to part of its name, e.g. `NFC_READER="ACR1252"`.
- Plugging the reader in or out while the program runs is fine; it is picked up within a second.
## Build and run
You need Rust (https://rustup.rs). Then, in this folder:
``` ```
cargo run --release # http://127.0.0.1:7252 (port: NFC_PORT) cargo run --release # desktop window
cargo run --release --bin nfc-writer-web # web page
``` ```
- Browsers can't talk to a USB smart-card reader, so a small Rust server (axum + `pcsc`, Windows `winscard`) does the reader access; the page polls `/api/status` and posts to `/api/program`. The binaries end up in `target/release/`. Build on the operating system you want to run it on.
- Supported tags: NFC Forum Type 2: NTAG213/215/216, MIFARE Ultralight / EV1 / C. MIFARE Classic and DESFire are detected and refused.
- Input starting with `http(s)://`, `www.`, `tel:`, `mailto:`, `sms:`, `geo:` or any `scheme://` is written as an NDEF URI record (`www.` gets `https://` in front); anything else as an NDEF text record (`en`). ### Windows
- The write empties the NDEF TLV first and writes its header last, so an interrupted write leaves an empty tag rather than a corrupt one. After writing, the data is read back and compared.
- Bound to 127.0.0.1 only. Nothing else to install: the smart-card service and a driver for the ACR1252U come with Windows.
### macOS
Install the Xcode command line tools (`xcode-select --install`). macOS has the smart-card service built in. If the reader is not listed, install the ACR1252U driver from the ACS website.
### Linux (Debian, Ubuntu)
```
sudo apt install pkg-config libpcsclite-dev pcscd libccid
sudo systemctl enable --now pcscd.socket
```
Fedora: `sudo dnf install pkgconf pcsc-lite-devel pcsc-lite pcsc-lite-ccid`. Arch: `sudo pacman -S pkgconf pcsclite ccid`.
## Tested
| | Windows 11 | macOS | Linux |
|---|---|---|---|
| Builds, unit tests, clippy | yes | type-checked only | type-checked only |
| Finds the ACR1252U | yes | not yet | not yet |
| Reads and writes a tag | not yet | not yet | not yet |
## Code
- `src/ndef.rs`: NDEF records and Type 2 TLVs (unit-tested).
- `src/reader.rs`: reader choice, card type from the ATR, reading and writing tag memory.
- `src/main.rs`: desktop window (egui).
- `src/bin/nfc-writer-web.rs` and `static/index.html`: web version (axum).
+77
View File
@@ -0,0 +1,77 @@
use nfc_writer::reader;
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};
const INDEX: &str = include_str!("../../static/index.html");
/// One reader, one operation at a time: the mutex serialises status polls and writes.
type Shared = Arc<Mutex<reader::Cache>>;
#[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");
}
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}"))
}
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 }),
)
}
#[derive(Deserialize)]
struct ProgramRequest {
text: String,
}
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(),
}
}
+4
View File
@@ -0,0 +1,4 @@
//! NFC tag writing through a PC/SC reader, shared by the desktop GUI and the web version.
pub mod ndef;
pub mod reader;
+267 -63
View File
@@ -1,78 +1,282 @@
mod ndef; //! Desktop GUI: same reader logic as the web version, in a native window (Windows, macOS, Linux).
mod reader;
use axum::{ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
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};
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. const POLL: Duration = Duration::from_millis(500);
type Shared = Arc<Mutex<reader::Cache>>; const HINT: &str = "Links (https://, www., tel:, mailto:, geo: …) are written as a link, anything else as plain text.";
#[tokio::main] #[derive(Default)]
async fn main() { struct Shared {
let port: u16 = std::env::var("NFC_PORT") status: Option<Status>,
.ok() busy: bool,
.and_then(|p| p.parse().ok()) result: Option<Result<Programmed, String>>,
.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");
} }
async fn with_reader<T: Send + 'static>( struct App {
s: Shared, text: String,
f: impl FnOnce(&mut reader::Cache) -> T + Send + 'static, shared: Arc<Mutex<Shared>>,
) -> Result<T, String> { jobs: mpsc::Sender<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}"))
} }
async fn status(State(s): State<Shared>) -> Json<reader::Status> { fn main() -> eframe::Result {
Json( let options = eframe::NativeOptions {
with_reader(s, reader::status) viewport: egui::ViewportBuilder::default()
.await .with_title("NFC tag writer")
.unwrap_or_else(|message| reader::Status::Error { message }), .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)] /// egui's built-in fonts have no Thai glyphs: add the first system font that has them.
struct ProgramRequest { fn add_thai_font(ctx: &egui::Context) {
text: String, 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 { /// Owns the reader: polls the status and runs program jobs, one at a time.
match with_reader(s, move |c| reader::program(c, &req.text)).await { fn worker(jobs: mpsc::Receiver<String>, shared: Arc<Mutex<Shared>>, ctx: egui::Context) {
Ok(Ok(done)) => Json(done).into_response(), let mut cache = Cache::default();
Ok(Err(error)) => ( loop {
StatusCode::UNPROCESSABLE_ENTITY, let job = match jobs.recv_timeout(POLL) {
Json(json!({ "error": error })), Ok(text) => Some(text),
) Err(mpsc::RecvTimeoutError::Timeout) => None,
.into_response(), Err(mpsc::RecvTimeoutError::Disconnected) => return,
Err(error) => ( };
StatusCode::INTERNAL_SERVER_ERROR, let result = job.map(|text| reader::program(&mut cache, &text));
Json(json!({ "error": error })), let status = reader::status(&mut cache);
) let mut s = shared.lock().unwrap_or_else(|e| e.into_inner());
.into_response(), 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();
}
});
} }
} }
+153 -30
View File
@@ -2,9 +2,11 @@
use crate::ndef::{self, Record, RecordView}; use crate::ndef::{self, Record, RecordView};
use pcsc::{ use pcsc::{
Attribute, Card, Context, Error as PcscError, MAX_BUFFER_SIZE, Protocols, Scope, ShareMode, Card, Context, Error as PcscError, MAX_BUFFER_SIZE, Protocols, ReaderState, Scope, ShareMode,
State,
}; };
use serde::Serialize; use serde::Serialize;
use std::time::Duration;
const GET_UID: [u8; 5] = [0xFF, 0xCA, 0x00, 0x00, 0x00]; const GET_UID: [u8; 5] = [0xFF, 0xCA, 0x00, 0x00, 0x00];
@@ -14,7 +16,7 @@ pub struct Cache {
tag: Option<TagInfo>, tag: Option<TagInfo>,
} }
#[derive(Clone, Serialize)] #[derive(Clone, Debug, PartialEq, Serialize)]
pub struct TagInfo { pub struct TagInfo {
pub uid: String, pub uid: String,
pub kind: String, pub kind: String,
@@ -27,7 +29,7 @@ pub struct TagInfo {
pub note: Option<String>, pub note: Option<String>,
} }
#[derive(Serialize)] #[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")] #[serde(tag = "state", rename_all = "snake_case")]
pub enum Status { pub enum Status {
NoReader { message: String }, NoReader { message: String },
@@ -36,7 +38,7 @@ pub enum Status {
Error { message: String }, Error { message: String },
} }
#[derive(Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct Programmed { pub struct Programmed {
pub uid: String, pub uid: String,
pub record: RecordView, pub record: RecordView,
@@ -55,34 +57,103 @@ struct Cc {
writable: bool, writable: bool,
} }
fn no_reader_message() -> String {
if cfg!(target_os = "linux") {
"No NFC reader found. Plug it in; Linux also needs the pcscd service and the libccid driver \
(e.g. sudo apt install pcscd libccid)."
.into()
} else if cfg!(target_os = "macos") {
"No NFC reader found. Plug it in; if macOS still does not list it, install the ACS reader driver."
.into()
} else {
"No NFC reader found. Is the reader plugged in?".into()
}
}
fn words(name: &str) -> impl Iterator<Item = String> + '_ {
name.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(str::to_lowercase)
}
/// SAM slots hold a secure-access module, never a tag.
fn is_sam_slot(name: &str) -> bool {
words(name).any(|w| w == "sam")
}
fn looks_contactless(name: &str) -> bool {
let lower = name.to_lowercase();
["picc", "contactless", "nfc"]
.iter()
.any(|k| lower.contains(k))
|| words(name).any(|w| w == "cl")
}
/// Choose the reader to use: `forced` (from NFC_READER) matches a name substring; otherwise skip
/// SAM slots and prefer a contactless-looking name, then a reader with a card on it, then the first.
pub fn pick_reader(names: &[String], present: &[bool], forced: Option<&str>) -> Option<usize> {
if let Some(f) = forced {
let f = f.to_lowercase();
return names.iter().position(|n| n.to_lowercase().contains(&f));
}
names
.iter()
.enumerate()
.filter(|(_, n)| !is_sam_slot(n))
.max_by_key(|(i, n)| {
(
looks_contactless(n),
present.get(*i).copied().unwrap_or(false),
std::cmp::Reverse(*i),
)
})
.map(|(i, _)| i)
}
fn open() -> Result<(String, Option<Card>), Status> { fn open() -> Result<(String, Option<Card>), Status> {
let no_reader = || Status::NoReader { let no_reader = || Status::NoReader {
message: "No NFC reader found. Is the ACR1252U plugged in?".into(), message: no_reader_message(),
}; };
let ctx = Context::establish(Scope::User).map_err(|e| match e { let pcsc_error = |e: PcscError| match e {
PcscError::NoService | PcscError::ServiceStopped | PcscError::NoReadersAvailable => { PcscError::NoService | PcscError::ServiceStopped | PcscError::NoReadersAvailable => {
no_reader() no_reader()
} }
e => Status::Error { e => Status::Error {
message: format!("PC/SC: {e}"), message: format!("PC/SC: {e}"),
}, },
})?; };
let readers = ctx.list_readers_owned().map_err(|e| match e { let ctx = Context::establish(Scope::User).map_err(pcsc_error)?;
PcscError::NoReadersAvailable | PcscError::NoService | PcscError::ServiceStopped => { let readers = ctx.list_readers_owned().map_err(pcsc_error)?;
no_reader() let names: Vec<String> = readers
}
e => Status::Error {
message: format!("PC/SC: {e}"),
},
})?;
// The ACR1252U shows up as two readers: "... PICC 0" (contactless) and "... SAM 0".
let reader = readers
.iter() .iter()
.find(|r| r.to_string_lossy().contains("PICC")) .map(|r| r.to_string_lossy().into_owned())
.or_else(|| readers.first()) .collect();
.ok_or_else(no_reader)?; // Zero timeout: just report whether each reader currently has a card on it.
let name = reader.to_string_lossy().into_owned(); let mut states: Vec<ReaderState> = readers
match ctx.connect(reader, ShareMode::Shared, Protocols::ANY) { .iter()
.map(|r| ReaderState::new(r.clone(), State::UNAWARE))
.collect();
let present: Vec<bool> = match ctx.get_status_change(Duration::ZERO, &mut states) {
Ok(()) => states
.iter()
.map(|s| s.event_state().contains(State::PRESENT))
.collect(),
Err(_) => vec![false; names.len()],
};
let forced = std::env::var("NFC_READER").ok().filter(|s| !s.is_empty());
let Some(i) = pick_reader(&names, &present, forced.as_deref()) else {
return Err(match forced {
Some(f) => Status::NoReader {
message: format!(
"No reader matches NFC_READER=\"{f}\" (found: {}).",
names.join(", ")
),
},
None => no_reader(),
});
};
let name = names[i].clone();
match ctx.connect(&readers[i], ShareMode::Shared, Protocols::ANY) {
Ok(card) => Ok((name, Some(card))), Ok(card) => Ok((name, Some(card))),
Err( Err(
PcscError::NoSmartcard PcscError::NoSmartcard
@@ -98,6 +169,13 @@ fn open() -> Result<(String, Option<Card>), Status> {
} }
} }
/// ATR via SCardStatus (works on winscard, pcsc-lite and macOS; SCardGetAttrib does not everywhere).
fn atr(card: &Card) -> Vec<u8> {
card.status2_owned()
.map(|s| s.atr().to_vec())
.unwrap_or_default()
}
fn apdu(card: &Card, cmd: &[u8]) -> Result<Vec<u8>, String> { fn apdu(card: &Card, cmd: &[u8]) -> Result<Vec<u8>, String> {
let mut buf = [0u8; MAX_BUFFER_SIZE]; let mut buf = [0u8; MAX_BUFFER_SIZE];
let r = card.transmit(cmd, &mut buf).map_err(|e| { let r = card.transmit(cmd, &mut buf).map_err(|e| {
@@ -195,10 +273,7 @@ fn inspect(card: &Card, uid: String) -> TagInfo {
records: vec![], records: vec![],
note: None, note: None,
}; };
let atr = card if let Kind::Unsupported(name) = kind(&atr(card)) {
.get_attribute_owned(Attribute::AtrString)
.unwrap_or_default();
if let Kind::Unsupported(name) = kind(&atr) {
info.kind = name; info.kind = name;
info.note = info.note =
Some("Only NTAG21x / MIFARE Ultralight (NFC Type 2) tags can be programmed.".into()); Some("Only NTAG21x / MIFARE Ultralight (NFC Type 2) tags can be programmed.".into());
@@ -291,10 +366,7 @@ pub fn program(cache: &mut Cache, input: &str) -> Result<Programmed, String> {
let card = let card =
card.ok_or("No tag on the reader. Place a tag on the reader and press Program again.")?; card.ok_or("No tag on the reader. Place a tag on the reader and press Program again.")?;
let uid = hex(&apdu(&card, &GET_UID)?); let uid = hex(&apdu(&card, &GET_UID)?);
let atr = card if let Kind::Unsupported(name) = kind(&atr(&card)) {
.get_attribute_owned(Attribute::AtrString)
.unwrap_or_default();
if let Kind::Unsupported(name) = kind(&atr) {
return Err(format!( return Err(format!(
"{name} is not supported. Use an NTAG21x / MIFARE Ultralight tag." "{name} is not supported. Use an NTAG21x / MIFARE Ultralight tag."
)); ));
@@ -336,3 +408,54 @@ pub fn program(cache: &mut Cache, input: &str) -> Result<Programmed, String> {
capacity: cc.capacity, capacity: cc.capacity,
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
fn names(n: &[&str]) -> Vec<String> {
n.iter().map(|s| s.to_string()).collect()
}
#[test]
fn picks_the_picc_slot_on_every_platform() {
// Windows, Linux/macOS (pcsc-lite naming)
for n in [
names(&[
"ACS ACR1252 Dual Reader SAM 0",
"ACS ACR1252 Dual Reader PICC 0",
]),
names(&[
"ACS ACR1252 Dual Reader [ACR1252 Dual Reader PICC] 00 00",
"ACS ACR1252 Dual Reader [ACR1252 Dual Reader SAM] 00 01",
]),
] {
let i = pick_reader(&n, &[false, false], None).unwrap();
assert!(n[i].contains("PICC"), "{n:?}");
}
}
#[test]
fn prefers_contactless_over_a_contact_card_and_present_over_absent() {
let n = names(&[
"Microsoft Usbccid Smartcard Reader (WUDF) 0",
"ACS ACR122U PICC Interface 0",
]);
assert_eq!(pick_reader(&n, &[true, false], None), Some(1));
let n = names(&[
"Alcor Micro USB Smart Card Reader 0",
"SCM Microsystems SCL3711 0",
]);
assert_eq!(pick_reader(&n, &[false, true], None), Some(1));
assert_eq!(pick_reader(&n, &[false, false], None), Some(0));
}
#[test]
fn sam_only_and_forced_reader() {
let n = names(&["ACS ACR1252 Dual Reader SAM 0"]);
assert_eq!(pick_reader(&n, &[false], None), None);
let n = names(&["A PICC 0", "B PICC 1"]);
assert_eq!(pick_reader(&n, &[false, false], Some("b picc")), Some(1));
assert_eq!(pick_reader(&n, &[false, false], Some("nope")), None);
}
}