From 8b95d0d5d7d2ee201a56432c1f0831ef591dc6bc Mon Sep 17 00:00:00 2001 From: grabowski Date: Thu, 29 Oct 2026 09:38:43 +0700 Subject: [PATCH] Read feature, release workflow (v0.3.0) - Read button (desktop and web): reads the tag now, bypassing the status cache, and puts its first link or text record into the input field so it can be copied to another tag. POST /api/read in the web version. - Raw memory: every 4-byte page from page 0 (UID, lock bytes, CC, data) in hex and ASCII, collapsed by default. - Content now shows the records and the note together (a locked tag showed only "read-only" before); read failures are flagged with read_failed instead of matching message text. - .gitea/workflows/release.yml: on a v* tag, tests and clippy, then Linux (ubuntu-22.04, glibc 2.35) and Windows (MinGW cross-build) packages attached to the tag's release with its CHANGELOG.md section. - README: reading, why the reader program runs on the reader's machine, releases. --- .gitea/scripts/release-attach.py | 93 ++++++++++ .gitea/workflows/release.yml | 88 +++++++++ CHANGELOG.md | 13 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 16 +- src/bin/nfc-writer-web.rs | 15 +- src/main.rs | 299 +++++++++++++++++++++---------- src/reader.rs | 79 ++++++-- static/index.html | 168 +++++++++++------ 10 files changed, 610 insertions(+), 165 deletions(-) create mode 100644 .gitea/scripts/release-attach.py create mode 100644 .gitea/workflows/release.yml create mode 100644 CHANGELOG.md diff --git a/.gitea/scripts/release-attach.py b/.gitea/scripts/release-attach.py new file mode 100644 index 0000000..e603bba --- /dev/null +++ b/.gitea/scripts/release-attach.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Attach files to the Gitea release of the current tag; create the release when missing. + + TOKEN= python3 .gitea/scripts/release-attach.py dist/*.zip dist/*.tar.gz + +Reads GITHUB_SERVER_URL, GITHUB_REPOSITORY and GITHUB_REF_NAME (the tag), as Gitea Actions sets +them. A new release gets the tag's CHANGELOG.md section as its text. An asset of the same name +from an earlier run of the tag is replaced. Used by .gitea/workflows/release.yml. +""" +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def changelog(tag): + path = ROOT / "CHANGELOG.md" + if not path.exists(): + return "" + text = path.read_text(encoding="utf-8") + m = re.search(rf"^## {re.escape(tag)}\b.*?$(.*?)(?=^## |\Z)", text, re.M | re.S) + return m.group(1).strip() if m else "" + + +class Gitea: + def __init__(self, server, repo, token): + self.api = f"{server.rstrip('/')}/api/v1/repos/{repo}" + self.auth = {"Authorization": f"token {token}"} + + def call(self, method, path, body=None, ctype="application/json"): + req = urllib.request.Request( + self.api + path, + data=body, + method=method, + headers={**self.auth, "Content-Type": ctype, "Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=300) as r: + data = r.read() + return json.loads(data) if data.strip() else None + + def release(self, tag): + try: + return self.call("GET", f"/releases/tags/{urllib.parse.quote(tag, safe='')}"), False + except urllib.error.HTTPError as e: + if e.code != 404: + raise + body = json.dumps({"tag_name": tag, "name": f"NFC tag writer {tag}", "body": changelog(tag)}) + return self.call("POST", "/releases", body.encode()), True + + def upload(self, rel, name, data): + b = uuid.uuid4().hex + head = ( + f'--{b}\r\nContent-Disposition: form-data; name="attachment"; filename="{name}"\r\n' + "Content-Type: application/octet-stream\r\n\r\n" + ).encode() + body = head + data + f"\r\n--{b}--\r\n".encode() + self.call( + "POST", + f"/releases/{rel['id']}/assets?name={urllib.parse.quote(name)}", + body, + f"multipart/form-data; boundary={b}", + ) + + +def main(): + paths = [Path(p) for p in sys.argv[1:]] + if not paths: + sys.exit(__doc__) + env = {k: os.environ.get(k, "") for k in ("GITHUB_SERVER_URL", "GITHUB_REPOSITORY", "GITHUB_REF_NAME", "TOKEN")} + missing = [k for k, v in env.items() if not v] + if missing: + sys.exit(f"missing environment: {', '.join(missing)}") + g = Gitea(env["GITHUB_SERVER_URL"], env["GITHUB_REPOSITORY"], env["TOKEN"]) + rel, created = g.release(env["GITHUB_REF_NAME"]) + print(("created" if created else "found") + f" release {env['GITHUB_REF_NAME']} (id {rel['id']})") + old = {a["name"]: a["id"] for a in rel.get("assets") or []} + for p in paths: + if p.name in old: + g.call("DELETE", f"/releases/{rel['id']}/assets/{old[p.name]}") + data = p.read_bytes() + g.upload(rel, p.name, data) + print(f" {'replaced' if p.name in old else 'attached'} {p.name} ({len(data):,} bytes)") + + +if __name__ == "__main__": + main() diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..920283b --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,88 @@ +# Release builds on git.b4l.co.th (Gitea Actions). +# +# On a v* tag: tests, then the Linux and Windows x86_64 binaries (desktop app +# and web version), packaged and attached to the tag's release (created when +# missing, text from CHANGELOG.md). On demand (workflow_dispatch) the same +# packages become the artifact `nfc-writer-packages` instead. +# +# Linux is built on ubuntu-22.04 so the binary needs only glibc 2.35 (Debian 12, +# Ubuntu 22.04 and newer). Windows is cross-built with MinGW-w64. macOS has no +# runner here: build it on a Mac (README, "Build and run"). +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUST_TOOLCHAIN: "1.98.1" + +jobs: + release: + runs-on: ubuntu-22.04 + timeout-minutes: 60 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: System packages + run: | + SUDO=$(command -v sudo || true) + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq curl ca-certificates gcc libc6-dev pkg-config \ + libpcsclite-dev gcc-mingw-w64-x86-64 zip python3 + + - name: Rust toolchain + run: | + if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none + fi + export PATH="$HOME/.cargo/bin:$PATH" + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal -c clippy -t x86_64-pc-windows-gnu + rustup default "$RUST_TOOLCHAIN" + rustc --version + + - name: Tests and clippy + run: | + cargo test --locked + cargo clippy --locked --all-targets -- -D warnings + + - name: Build + run: | + cargo build --locked --release + cargo build --locked --release --target x86_64-pc-windows-gnu + + - name: Package + run: | + case "$GITHUB_REF" in + refs/tags/*) version="$GITHUB_REF_NAME" ;; + *) version="dev-${GITHUB_SHA::7}" ;; + esac + mkdir -p dist + linux="nfc-writer-$version-linux-x86_64" + mkdir "$linux" + cp target/release/nfc-writer target/release/nfc-writer-web README.md "$linux/" + tar czf "dist/$linux.tar.gz" "$linux" + windows="nfc-writer-$version-windows-x86_64" + mkdir "$windows" + cp target/x86_64-pc-windows-gnu/release/nfc-writer.exe \ + target/x86_64-pc-windows-gnu/release/nfc-writer-web.exe README.md "$windows/" + zip -qr "dist/$windows.zip" "$windows" + ls -l dist + + - name: Attach to the release + if: startsWith(github.ref, 'refs/tags/v') + env: + TOKEN: ${{ secrets.GITEA_TOKEN }} + run: python3 .gitea/scripts/release-attach.py dist/* + + - name: Artifact (on-demand runs) + if: "!startsWith(github.ref, 'refs/tags/v')" + uses: https://github.com/christopherHX/gitea-upload-artifact@v4 + with: + name: nfc-writer-packages + path: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..75ab90c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## v0.3.0 + +First release. + +- Desktop app `nfc-writer`: type a link or some text, put a tag on the reader, press **Program**. No browser or network needed. +- Web version `nfc-writer-web`: the same on a local page at http://127.0.0.1:7252. +- **Read** reads the tag now and puts its link or text into the field, so it can be copied to another tag. "Raw memory" shows every page of the tag in hex. +- Writes NDEF links and text to NTAG213/215/216 and MIFARE Ultralight tags, then reads them back to check. +- Finds the reader by itself: the contactless slot of the ACS ACR1252U, or any other PC/SC contactless reader. `NFC_READER` picks one by name. + +Downloads below: Windows and Linux (x86_64). On Linux, install `pcscd` and `libccid` first. For macOS, build from source (see README). diff --git a/Cargo.lock b/Cargo.lock index 1c020f7..77dc588 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2049,7 +2049,7 @@ dependencies = [ [[package]] name = "nfc-writer" -version = "0.2.0" +version = "0.3.0" dependencies = [ "axum", "eframe", diff --git a/Cargo.toml b/Cargo.toml index 4449220..d1b2e48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nfc-writer" -version = "0.2.0" +version = "0.3.0" edition = "2024" 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" diff --git a/README.md b/README.md index 0384c3b..cca0606 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # NFC tag writer -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**. +Programs and reads 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**. Press **Read** to load a tag's link or text into the field, for example to copy it to another tag. + +Downloads for Windows and Linux: https://git.b4l.co.th/B4L/NFC/releases There are two ways to run it, with the same reader logic behind both: @@ -16,6 +18,10 @@ There are two ways to run it, with the same reader logic behind both: - 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. +## What it reads + +As soon as a tag is on the reader, the window shows its UID, chip type, memory use and content. **Read** reads the tag again and puts its first link or text into the input field. "Raw memory" lists every 4-byte page of the tag in hex and ASCII. Any card shows its UID; only Type 2 tags show content. + ## 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. @@ -53,6 +59,14 @@ 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`. +## Why it runs on the machine with the reader + +Browsers cannot reach a USB smart-card reader: WebUSB blocks the smart-card device class, and Web NFC exists only in Chrome on Android. So the program that talks to the reader has to run on the computer the reader is plugged into. The web version is a convenience for that same computer; hosting it on a server would only reach the server's own USB ports. + +## Releases + +Push a tag `v*` (with a matching section in `CHANGELOG.md`): `.gitea/workflows/release.yml` tests, builds Linux and Windows x86_64 packages and attaches them to the tag's release. + ## Tested | | Windows 11 | macOS | Linux | diff --git a/src/bin/nfc-writer-web.rs b/src/bin/nfc-writer-web.rs index 1c4f019..1012158 100644 --- a/src/bin/nfc-writer-web.rs +++ b/src/bin/nfc-writer-web.rs @@ -26,6 +26,7 @@ async fn main() { .route("/", get(|| async { Html(INDEX) })) .route("/api/status", get(status)) .route("/api/program", post(program)) + .route("/api/read", post(read)) .with_state(Shared::default()); let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); let listener = tokio::net::TcpListener::bind(addr) @@ -60,9 +61,9 @@ struct ProgramRequest { text: String, } -async fn program(State(s): State, Json(req): Json) -> Response { - match with_reader(s, move |c| reader::program(c, &req.text)).await { - Ok(Ok(done)) => Json(done).into_response(), +fn reply(r: Result, String>) -> Response { + match r { + Ok(Ok(body)) => Json(body).into_response(), Ok(Err(error)) => ( StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": error })), @@ -75,3 +76,11 @@ async fn program(State(s): State, Json(req): Json) -> Re .into_response(), } } + +async fn program(State(s): State, Json(req): Json) -> Response { + reply(with_reader(s, move |c| reader::program(c, &req.text)).await) +} + +async fn read(State(s): State) -> Response { + reply(with_reader(s, reader::read).await) +} diff --git a/src/main.rs b/src/main.rs index d7d2132..d50c751 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,27 +8,47 @@ use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; 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."; +const HINT: &str = "Links (https://, www., tel:, mailto:, geo: …) are written as a link, anything else as plain text. \ + Read puts the tag's content into the field, so you can copy it to another tag."; + +#[derive(Clone, Copy, PartialEq)] +enum JobKind { + Read, + Program, +} + +enum Job { + Read, + Program(String), +} + +#[derive(Clone)] +enum Outcome { + Read(TagInfo), + Wrote(Programmed), +} #[derive(Default)] struct Shared { status: Option, - busy: bool, - result: Option>, + busy: Option, + result: Option>, + /// Text for the input field after a read; the UI takes it on the next frame. + fill: Option, } struct App { text: String, shared: Arc>, - jobs: mpsc::Sender, + jobs: mpsc::Sender, } 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]), + .with_inner_size([560.0, 390.0]) + .with_min_inner_size([420.0, 320.0]), ..Default::default() }; eframe::run_native( @@ -70,22 +90,36 @@ fn add_thai_font(ctx: &egui::Context) { ctx.set_fonts(fonts); } -/// Owns the reader: polls the status and runs program jobs, one at a time. -fn worker(jobs: mpsc::Receiver, shared: Arc>, ctx: egui::Context) { +/// Text a read puts into the input field: the first link or text record. +fn fill_text(tag: &TagInfo) -> Option { + tag.records + .first() + .filter(|r| r.kind == "URL" || r.kind == "Text") + .map(|r| r.value.clone()) +} + +/// Owns the reader: polls the status and runs read/program jobs, one at a time. +fn worker(jobs: mpsc::Receiver, shared: Arc>, ctx: egui::Context) { let mut cache = Cache::default(); loop { let job = match jobs.recv_timeout(POLL) { - Ok(text) => Some(text), + Ok(job) => Some(job), Err(mpsc::RecvTimeoutError::Timeout) => None, Err(mpsc::RecvTimeoutError::Disconnected) => return, }; - let result = job.map(|text| reader::program(&mut cache, &text)); + let result = job.map(|job| match job { + Job::Read => reader::read(&mut cache).map(Outcome::Read), + Job::Program(text) => reader::program(&mut cache, &text).map(Outcome::Wrote), + }); 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 { + if let Ok(Outcome::Read(tag)) = &r { + s.fill = fill_text(tag); + } s.result = Some(r); - s.busy = false; + s.busy = None; } s.status = Some(status); drop(s); @@ -108,14 +142,17 @@ impl App { } } - fn program(&mut self) { + fn start(&mut self, kind: JobKind) { let mut s = self.shared.lock().unwrap_or_else(|e| e.into_inner()); - if s.busy { + if s.busy.is_some() { return; } - s.busy = true; + s.busy = Some(kind); s.result = None; - let _ = self.jobs.send(self.text.clone()); + let _ = self.jobs.send(match kind { + JobKind::Read => Job::Read, + JobKind::Program => Job::Program(self.text.clone()), + }); } } @@ -182,101 +219,177 @@ fn tag_details(ui: &mut egui::Ui, tag: &TagInfo, p: &Palette) { ui.end_row(); ui.weak("Content"); ui.vertical(|ui| { + 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); + } + }); + } + if tag.records.is_empty() && tag.formatted && !tag.read_failed { + ui.label("empty"); + } 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(); }); + if !tag.raw.is_empty() { + egui::CollapsingHeader::new(format!("Raw memory ({} pages)", tag.raw.len() / 4)) + .id_salt("raw") + .show(ui, |ui| { + egui::ScrollArea::vertical() + .max_height(160.0) + .show(ui, |ui| { + ui.monospace(reader::page_dump(&tag.raw).join("\n")); + }); + }); + } +} + +fn result_line(ui: &mut egui::Ui, result: &Result, p: &Palette) { + match result { + Ok(Outcome::Wrote(done)) => { + ui.colored_label( + p.ok, + format!( + "✔ Wrote {}: {} ({} of {} bytes, verified)", + done.record.kind, done.record.value, done.bytes, done.capacity + ), + ); + } + Ok(Outcome::Read(tag)) => { + let n = tag.records.len(); + let text = if fill_text(tag).is_some() { + format!( + "✔ Read {}: {n} record{}. The first one is now in the field: put another tag on and press Program to copy it.", + tag.uid, + if n == 1 { "" } else { "s" } + ) + } else if n > 0 { + format!( + "✔ Read {}: {n} record(s), none of them a link or text.", + tag.uid + ) + } else if tag.note.is_some() { + format!( + "Read {} ({}): UID only, no NDEF content.", + tag.uid, tag.kind + ) + } else { + format!("✔ Read {}: the tag is empty.", tag.uid) + }; + ui.colored_label(if tag.note.is_some() { p.warn } else { p.ok }, text); + } + Err(e) => { + ui.colored_label(p.err, e); + } + } } 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()); + let mut s = self.shared.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(fill) = s.fill.take() { + self.text = fill; + } (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")); + egui::ScrollArea::vertical() + .auto_shrink(false) + .show(ui, |ui| { + let p = palette(ui.visuals().dark_mode); ui.add_space(4.0); - tag_details(ui, tag, &p); - } - } + ui.heading("NFC tag writer"); + ui.add_space(8.0); - 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 + let mut writable = false; + let mut present = 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(Err(e)) => { - ui.colored_label(p.err, e); - } - None => {} - } - if submit && writable && !busy { - self.program(); - } + Some(Status::Card { reader, tag }) => { + present = true; + 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 job = None; + ui.horizontal(|ui| { + let (read_width, program_width) = (80.0, 110.0); + let edit_width = ui.available_width() + - read_width + - program_width + - 2.0 * 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)) { + if writable { + job = Some(JobKind::Program); + } + edit.request_focus(); + } + let read_label = if busy == Some(JobKind::Read) { + "Reading…" + } else { + "Read" + }; + let read = + egui::Button::new(read_label).min_size(egui::vec2(read_width, 30.0)); + if ui.add_enabled(present && busy.is_none(), read).clicked() { + job = Some(JobKind::Read); + } + let program_label = if busy == Some(JobKind::Program) { + "Programming…" + } else { + "Program" + }; + let program = egui::Button::new( + RichText::new(program_label).strong().color(Color32::WHITE), + ) + .fill(p.accent) + .min_size(egui::vec2(program_width, 30.0)); + if ui + .add_enabled(writable && busy.is_none(), program) + .clicked() + { + job = Some(JobKind::Program); + } + }); + ui.add_space(4.0); + ui.small(RichText::new(HINT).weak()); + ui.add_space(8.0); + if let Some(result) = &result { + result_line(ui, result, &p); + } + if let Some(kind) = job { + self.start(kind); + } + }); }); } } diff --git a/src/reader.rs b/src/reader.rs index 0b6ffe1..930a18b 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -27,6 +27,10 @@ pub struct TagInfo { pub records: Vec, /// Why the tag cannot be programmed, or a read error. pub note: Option, + /// Tag memory from page 0 (UID, lock bytes, capability container, data area), 4 bytes per page. + pub raw: Vec, + /// The tag left the reader (or failed) mid-read: the info is incomplete. + pub read_failed: bool, } #[derive(Clone, Debug, PartialEq, Serialize)] @@ -230,11 +234,8 @@ fn type2_name(cc: &Cc) -> String { format!("NFC Type 2{chip}") } -fn read_cc(card: &Card) -> Result { - let d = apdu(card, &[0xFF, 0xB0, 0x00, 0x03, 0x10])?; // pages 3..6 - let cc = d - .get(..4) - .ok_or("Could not read the capability container.")?; +fn parse_cc(cc: Option<&[u8]>) -> Result { + let cc = cc.ok_or("Could not read the capability container.")?; Ok(Cc { formatted: cc[0] == 0xE1, capacity: cc[2] as usize * 8, @@ -243,6 +244,11 @@ fn read_cc(card: &Card) -> Result { }) } +fn read_cc(card: &Card) -> Result { + let d = apdu(card, &[0xFF, 0xB0, 0x00, 0x03, 0x10])?; // pages 3..6 + parse_cc(d.get(..4)) +} + /// Read `len` bytes starting at page 4, 16 bytes (4 pages) per command. fn read_data(card: &Card, len: usize) -> Result, String> { let mut out = Vec::with_capacity(len + 16); @@ -272,6 +278,8 @@ fn inspect(card: &Card, uid: String) -> TagInfo { writable: false, records: vec![], note: None, + raw: vec![], + read_failed: false, }; if let Kind::Unsupported(name) = kind(&atr(card)) { info.kind = name; @@ -280,7 +288,8 @@ fn inspect(card: &Card, uid: String) -> TagInfo { return info; } let result = (|| -> Result<(), String> { - let cc = read_cc(card)?; + info.raw = apdu(card, &[0xFF, 0xB0, 0x00, 0x00, 0x10])?; // pages 0..3 + let cc = parse_cc(info.raw.get(12..16))?; info.kind = type2_name(&cc); info.formatted = cc.formatted; info.writable = cc.writable; @@ -293,6 +302,8 @@ fn inspect(card: &Card, uid: String) -> TagInfo { info.note = Some("Tag is locked (read-only).".into()); } let area = read_data(card, cc.capacity)?; + info.raw.truncate(16); + info.raw.extend_from_slice(&area); match ndef::find_message(&area)? { Some((msg, used)) => { info.used = Some(used); @@ -307,6 +318,7 @@ fn inspect(card: &Card, uid: String) -> TagInfo { info.kind = "NFC Type 2".into(); } info.note = Some(e); + info.read_failed = true; } info } @@ -335,17 +347,49 @@ pub fn status(cache: &mut Cache) -> Status { } let tag = inspect(&card, uid); // Don't cache a failed read (tag moved away mid-read): retry on the next poll. - let failed = tag - .note - .as_deref() - .is_some_and(|n| n.contains("communication") || n.contains("rejected")); - cache.tag = (!failed).then(|| tag.clone()); + cache.tag = (!tag.read_failed).then(|| tag.clone()); Status::Card { reader, tag: Box::new(tag), } } +/// One line per 4-byte page: page number, hex bytes, printable ASCII. +pub fn page_dump(raw: &[u8]) -> Vec { + raw.chunks(4) + .enumerate() + .map(|(page, bytes)| { + let hex: Vec = bytes.iter().map(|b| format!("{b:02X}")).collect(); + let ascii: String = bytes + .iter() + .map(|&b| { + if (0x20..0x7f).contains(&b) { + b as char + } else { + '.' + } + }) + .collect(); + format!("{page:>3} {:<11} {ascii}", hex.join(" ")) + }) + .collect() +} + +/// Read the tag on the reader now, bypassing the cache. +pub fn read(cache: &mut Cache) -> Result { + let (_, card) = open().map_err(status_message)?; + let card = + card.ok_or("No tag on the reader. Place a tag on the reader and press Read again.")?; + let uid = hex(&apdu(&card, &GET_UID)?); + let tag = inspect(&card, uid); + if tag.read_failed { + cache.tag = None; + return Err(tag.note.unwrap_or_else(|| "Reading the tag failed.".into())); + } + cache.tag = Some(tag.clone()); + Ok(tag) +} + fn status_message(s: Status) -> String { match s { Status::NoReader { message } | Status::Error { message } => message, @@ -417,6 +461,19 @@ mod tests { n.iter().map(|s| s.to_string()).collect() } + #[test] + fn page_dump_shows_hex_and_ascii() { + let lines = page_dump(&[0x03, 0x12, 0xD1, 0x01, b'U', 0x04, b'b', b'u', 0xFE]); + assert_eq!( + lines, + vec![ + " 0 03 12 D1 01 ....", + " 1 55 04 62 75 U.bu", + " 2 FE ." + ] + ); + } + #[test] fn picks_the_picc_slot_on_every_platform() { // Windows, Linux/macOS (pcsc-lite naming) diff --git a/static/index.html b/static/index.html index 5def8f6..9f27f6b 100644 --- a/static/index.html +++ b/static/index.html @@ -54,7 +54,11 @@ padding: 9px 18px; font: inherit; font-weight: 600; color: #fff; background: var(--accent); border: 0; border-radius: 8px; cursor: pointer; } + button.secondary { color: var(--fg); background: transparent; border: 1px solid var(--border); font-weight: 500; } button:disabled { opacity: .45; cursor: not-allowed; } + details { margin: 8px 4px 0; font-size: 13px; } + summary { color: var(--muted); cursor: pointer; } + pre { max-height: 180px; overflow: auto; margin: 6px 0 0; padding: 8px 10px; font-size: 12px; border: 1px solid var(--border); border-radius: 8px; } .hint { margin: 6px 2px 0; font-size: 12px; color: var(--muted); } .msg { min-height: 1.4em; margin-top: 12px; font-size: 14px; } .msg.ok { color: var(--ok); } @@ -71,24 +75,78 @@
Memory
Content
+
+
-

URLs (https://, www., tel:, mailto:, geo: …) are written as a link, anything else as plain text.

+

Links (https://, www., tel:, mailto:, geo: …) are written as a link, anything else as plain text. Read puts the tag's content into the field, so you can copy it to another tag.