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:
1 parent
449cdb7af5
commit
cb1b1b04c8
7 files changed
+4820
-111
No files matched your search
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+153
-30
@@ -2,9 +2,11 @@
|
||||
|
||||
use crate::ndef::{self, Record, RecordView};
|
||||
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 std::time::Duration;
|
||||
|
||||
const GET_UID: [u8; 5] = [0xFF, 0xCA, 0x00, 0x00, 0x00];
|
||||
|
||||
@@ -14,7 +16,7 @@ pub struct Cache {
|
||||
tag: Option<TagInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct TagInfo {
|
||||
pub uid: String,
|
||||
pub kind: String,
|
||||
@@ -27,7 +29,7 @@ pub struct TagInfo {
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum Status {
|
||||
NoReader { message: String },
|
||||
@@ -36,7 +38,7 @@ pub enum Status {
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Programmed {
|
||||
pub uid: String,
|
||||
pub record: RecordView,
|
||||
@@ -55,34 +57,103 @@ struct Cc {
|
||||
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> {
|
||||
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 => {
|
||||
no_reader()
|
||||
}
|
||||
e => Status::Error {
|
||||
message: format!("PC/SC: {e}"),
|
||||
},
|
||||
})?;
|
||||
let readers = ctx.list_readers_owned().map_err(|e| match e {
|
||||
PcscError::NoReadersAvailable | PcscError::NoService | PcscError::ServiceStopped => {
|
||||
no_reader()
|
||||
}
|
||||
e => Status::Error {
|
||||
message: format!("PC/SC: {e}"),
|
||||
},
|
||||
})?;
|
||||
// The ACR1252U shows up as two readers: "... PICC 0" (contactless) and "... SAM 0".
|
||||
let reader = readers
|
||||
};
|
||||
let ctx = Context::establish(Scope::User).map_err(pcsc_error)?;
|
||||
let readers = ctx.list_readers_owned().map_err(pcsc_error)?;
|
||||
let names: Vec<String> = readers
|
||||
.iter()
|
||||
.find(|r| r.to_string_lossy().contains("PICC"))
|
||||
.or_else(|| readers.first())
|
||||
.ok_or_else(no_reader)?;
|
||||
let name = reader.to_string_lossy().into_owned();
|
||||
match ctx.connect(reader, ShareMode::Shared, Protocols::ANY) {
|
||||
.map(|r| r.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
// Zero timeout: just report whether each reader currently has a card on it.
|
||||
let mut states: Vec<ReaderState> = readers
|
||||
.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))),
|
||||
Err(
|
||||
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> {
|
||||
let mut buf = [0u8; MAX_BUFFER_SIZE];
|
||||
let r = card.transmit(cmd, &mut buf).map_err(|e| {
|
||||
@@ -195,10 +273,7 @@ fn inspect(card: &Card, uid: String) -> TagInfo {
|
||||
records: vec![],
|
||||
note: None,
|
||||
};
|
||||
let atr = card
|
||||
.get_attribute_owned(Attribute::AtrString)
|
||||
.unwrap_or_default();
|
||||
if let Kind::Unsupported(name) = kind(&atr) {
|
||||
if let Kind::Unsupported(name) = kind(&atr(card)) {
|
||||
info.kind = name;
|
||||
info.note =
|
||||
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 =
|
||||
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 atr = card
|
||||
.get_attribute_owned(Attribute::AtrString)
|
||||
.unwrap_or_default();
|
||||
if let Kind::Unsupported(name) = kind(&atr) {
|
||||
if let Kind::Unsupported(name) = kind(&atr(&card)) {
|
||||
return Err(format!(
|
||||
"{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,
|
||||
})
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user