Read feature, release workflow (v0.3.0)
Release / release (push) Successful in 6m46s

- 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.
This commit is contained in:
grabowski committed 2026-10-29 09:38:43 +07:00
1 parent cb1b1b04c8
commit 8b95d0d5d7
10 files changed
+610 -165

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Attach files to the Gitea release of the current tag; create the release when missing.
TOKEN=<job 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()
+88
View File
@@ -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/
+13
View File
@@ -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).
Generated
+1 -1
View File
@@ -2049,7 +2049,7 @@ dependencies = [
[[package]] [[package]]
name = "nfc-writer" name = "nfc-writer"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"axum", "axum",
"eframe", "eframe",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "nfc-writer" name = "nfc-writer"
version = "0.2.0" version = "0.3.0"
edition = "2024" 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" 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" default-run = "nfc-writer"
+15 -1
View File
@@ -1,6 +1,8 @@
# NFC tag writer # 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: 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. - 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. - 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 ## 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. 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`. 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 ## Tested
| | Windows 11 | macOS | Linux | | | Windows 11 | macOS | Linux |
+12 -3
View File
@@ -26,6 +26,7 @@ async fn main() {
.route("/", get(|| async { Html(INDEX) })) .route("/", get(|| async { Html(INDEX) }))
.route("/api/status", get(status)) .route("/api/status", get(status))
.route("/api/program", post(program)) .route("/api/program", post(program))
.route("/api/read", post(read))
.with_state(Shared::default()); .with_state(Shared::default());
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let listener = tokio::net::TcpListener::bind(addr) let listener = tokio::net::TcpListener::bind(addr)
@@ -60,9 +61,9 @@ struct ProgramRequest {
text: String, text: String,
} }
async fn program(State(s): State<Shared>, Json(req): Json<ProgramRequest>) -> Response { fn reply<T: serde::Serialize>(r: Result<Result<T, String>, String>) -> Response {
match with_reader(s, move |c| reader::program(c, &req.text)).await { match r {
Ok(Ok(done)) => Json(done).into_response(), Ok(Ok(body)) => Json(body).into_response(),
Ok(Err(error)) => ( Ok(Err(error)) => (
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": error })), Json(json!({ "error": error })),
@@ -75,3 +76,11 @@ async fn program(State(s): State<Shared>, Json(req): Json<ProgramRequest>) -> Re
.into_response(), .into_response(),
} }
} }
async fn program(State(s): State<Shared>, Json(req): Json<ProgramRequest>) -> Response {
reply(with_reader(s, move |c| reader::program(c, &req.text)).await)
}
async fn read(State(s): State<Shared>) -> Response {
reply(with_reader(s, reader::read).await)
}
+206 -93
View File
@@ -8,27 +8,47 @@ use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration; use std::time::Duration;
const POLL: Duration = Duration::from_millis(500); 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)] #[derive(Default)]
struct Shared { struct Shared {
status: Option<Status>, status: Option<Status>,
busy: bool, busy: Option<JobKind>,
result: Option<Result<Programmed, String>>, result: Option<Result<Outcome, String>>,
/// Text for the input field after a read; the UI takes it on the next frame.
fill: Option<String>,
} }
struct App { struct App {
text: String, text: String,
shared: Arc<Mutex<Shared>>, shared: Arc<Mutex<Shared>>,
jobs: mpsc::Sender<String>, jobs: mpsc::Sender<Job>,
} }
fn main() -> eframe::Result { fn main() -> eframe::Result {
let options = eframe::NativeOptions { let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_title("NFC tag writer") .with_title("NFC tag writer")
.with_inner_size([540.0, 330.0]) .with_inner_size([560.0, 390.0])
.with_min_inner_size([400.0, 300.0]), .with_min_inner_size([420.0, 320.0]),
..Default::default() ..Default::default()
}; };
eframe::run_native( eframe::run_native(
@@ -70,22 +90,36 @@ fn add_thai_font(ctx: &egui::Context) {
ctx.set_fonts(fonts); ctx.set_fonts(fonts);
} }
/// Owns the reader: polls the status and runs program jobs, one at a time. /// Text a read puts into the input field: the first link or text record.
fn worker(jobs: mpsc::Receiver<String>, shared: Arc<Mutex<Shared>>, ctx: egui::Context) { fn fill_text(tag: &TagInfo) -> Option<String> {
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<Job>, shared: Arc<Mutex<Shared>>, ctx: egui::Context) {
let mut cache = Cache::default(); let mut cache = Cache::default();
loop { loop {
let job = match jobs.recv_timeout(POLL) { let job = match jobs.recv_timeout(POLL) {
Ok(text) => Some(text), Ok(job) => Some(job),
Err(mpsc::RecvTimeoutError::Timeout) => None, Err(mpsc::RecvTimeoutError::Timeout) => None,
Err(mpsc::RecvTimeoutError::Disconnected) => return, 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 status = reader::status(&mut cache);
let mut s = shared.lock().unwrap_or_else(|e| e.into_inner()); let mut s = shared.lock().unwrap_or_else(|e| e.into_inner());
let changed = result.is_some() || s.status.as_ref() != Some(&status); let changed = result.is_some() || s.status.as_ref() != Some(&status);
if let Some(r) = result { if let Some(r) = result {
if let Ok(Outcome::Read(tag)) = &r {
s.fill = fill_text(tag);
}
s.result = Some(r); s.result = Some(r);
s.busy = false; s.busy = None;
} }
s.status = Some(status); s.status = Some(status);
drop(s); 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()); let mut s = self.shared.lock().unwrap_or_else(|e| e.into_inner());
if s.busy { if s.busy.is_some() {
return; return;
} }
s.busy = true; s.busy = Some(kind);
s.result = None; 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.end_row();
ui.weak("Content"); ui.weak("Content");
ui.vertical(|ui| { 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 { if let Some(note) = &tag.note {
ui.colored_label(p.warn, 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(); 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<Outcome, String>, 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 { impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let (status, busy, result) = { 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()) (s.status.clone(), s.busy, s.result.clone())
}; };
egui::CentralPanel::default_margins().show(ui, |ui| { egui::CentralPanel::default_margins().show(ui, |ui| {
let p = palette(ui.visuals().dark_mode); egui::ScrollArea::vertical()
ui.add_space(4.0); .auto_shrink(false)
ui.heading("NFC tag writer"); .show(ui, |ui| {
ui.add_space(8.0); let p = palette(ui.visuals().dark_mode);
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); 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 writable = false;
let mut submit = false; let mut present = false;
ui.horizontal(|ui| { match &status {
let button_width = 110.0; None => {
let edit_width = ui.available_width() - button_width - ui.spacing().item_spacing.x; status_line(ui, ui.visuals().weak_text_color(), "Looking for a reader…")
let edit = ui.add_sized( }
[edit_width, 30.0], Some(Status::NoReader { message } | Status::Error { message }) => {
egui::TextEdit::singleline(&mut self.text) status_line(ui, p.err, message)
.hint_text("https://buildfor.life or any text") }
.align(egui::Align2::LEFT_CENTER), Some(Status::NoCard { reader }) => status_line(
); ui,
if edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { p.warn,
submit = true; &format!("{reader} — place a tag on the reader"),
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(Status::Card { reader, tag }) => {
} present = true;
Some(Err(e)) => { writable = can_write(tag);
ui.colored_label(p.err, e); let color = if writable { p.ok } else { p.warn };
} status_line(ui, color, &format!("{reader} — tag present"));
None => {} ui.add_space(4.0);
} tag_details(ui, tag, &p);
if submit && writable && !busy { }
self.program(); }
}
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);
}
});
}); });
} }
} }
+68 -11
View File
@@ -27,6 +27,10 @@ pub struct TagInfo {
pub records: Vec<RecordView>, pub records: Vec<RecordView>,
/// Why the tag cannot be programmed, or a read error. /// Why the tag cannot be programmed, or a read error.
pub note: Option<String>, pub note: Option<String>,
/// Tag memory from page 0 (UID, lock bytes, capability container, data area), 4 bytes per page.
pub raw: Vec<u8>,
/// The tag left the reader (or failed) mid-read: the info is incomplete.
pub read_failed: bool,
} }
#[derive(Clone, Debug, PartialEq, Serialize)] #[derive(Clone, Debug, PartialEq, Serialize)]
@@ -230,11 +234,8 @@ fn type2_name(cc: &Cc) -> String {
format!("NFC Type 2{chip}") format!("NFC Type 2{chip}")
} }
fn read_cc(card: &Card) -> Result<Cc, String> { fn parse_cc(cc: Option<&[u8]>) -> Result<Cc, String> {
let d = apdu(card, &[0xFF, 0xB0, 0x00, 0x03, 0x10])?; // pages 3..6 let cc = cc.ok_or("Could not read the capability container.")?;
let cc = d
.get(..4)
.ok_or("Could not read the capability container.")?;
Ok(Cc { Ok(Cc {
formatted: cc[0] == 0xE1, formatted: cc[0] == 0xE1,
capacity: cc[2] as usize * 8, capacity: cc[2] as usize * 8,
@@ -243,6 +244,11 @@ fn read_cc(card: &Card) -> Result<Cc, String> {
}) })
} }
fn read_cc(card: &Card) -> Result<Cc, String> {
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. /// Read `len` bytes starting at page 4, 16 bytes (4 pages) per command.
fn read_data(card: &Card, len: usize) -> Result<Vec<u8>, String> { fn read_data(card: &Card, len: usize) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(len + 16); let mut out = Vec::with_capacity(len + 16);
@@ -272,6 +278,8 @@ fn inspect(card: &Card, uid: String) -> TagInfo {
writable: false, writable: false,
records: vec![], records: vec![],
note: None, note: None,
raw: vec![],
read_failed: false,
}; };
if let Kind::Unsupported(name) = kind(&atr(card)) { if let Kind::Unsupported(name) = kind(&atr(card)) {
info.kind = name; info.kind = name;
@@ -280,7 +288,8 @@ fn inspect(card: &Card, uid: String) -> TagInfo {
return info; return info;
} }
let result = (|| -> Result<(), String> { 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.kind = type2_name(&cc);
info.formatted = cc.formatted; info.formatted = cc.formatted;
info.writable = cc.writable; info.writable = cc.writable;
@@ -293,6 +302,8 @@ fn inspect(card: &Card, uid: String) -> TagInfo {
info.note = Some("Tag is locked (read-only).".into()); info.note = Some("Tag is locked (read-only).".into());
} }
let area = read_data(card, cc.capacity)?; let area = read_data(card, cc.capacity)?;
info.raw.truncate(16);
info.raw.extend_from_slice(&area);
match ndef::find_message(&area)? { match ndef::find_message(&area)? {
Some((msg, used)) => { Some((msg, used)) => {
info.used = Some(used); info.used = Some(used);
@@ -307,6 +318,7 @@ fn inspect(card: &Card, uid: String) -> TagInfo {
info.kind = "NFC Type 2".into(); info.kind = "NFC Type 2".into();
} }
info.note = Some(e); info.note = Some(e);
info.read_failed = true;
} }
info info
} }
@@ -335,17 +347,49 @@ pub fn status(cache: &mut Cache) -> Status {
} }
let tag = inspect(&card, uid); let tag = inspect(&card, uid);
// Don't cache a failed read (tag moved away mid-read): retry on the next poll. // Don't cache a failed read (tag moved away mid-read): retry on the next poll.
let failed = tag cache.tag = (!tag.read_failed).then(|| tag.clone());
.note
.as_deref()
.is_some_and(|n| n.contains("communication") || n.contains("rejected"));
cache.tag = (!failed).then(|| tag.clone());
Status::Card { Status::Card {
reader, reader,
tag: Box::new(tag), tag: Box::new(tag),
} }
} }
/// One line per 4-byte page: page number, hex bytes, printable ASCII.
pub fn page_dump(raw: &[u8]) -> Vec<String> {
raw.chunks(4)
.enumerate()
.map(|(page, bytes)| {
let hex: Vec<String> = 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<TagInfo, String> {
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 { fn status_message(s: Status) -> String {
match s { match s {
Status::NoReader { message } | Status::Error { message } => message, Status::NoReader { message } | Status::Error { message } => message,
@@ -417,6 +461,19 @@ mod tests {
n.iter().map(|s| s.to_string()).collect() 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] #[test]
fn picks_the_picc_slot_on_every_platform() { fn picks_the_picc_slot_on_every_platform() {
// Windows, Linux/macOS (pcsc-lite naming) // Windows, Linux/macOS (pcsc-lite naming)
+113 -55
View File
@@ -54,7 +54,11 @@
padding: 9px 18px; font: inherit; font-weight: 600; color: #fff; padding: 9px 18px; font: inherit; font-weight: 600; color: #fff;
background: var(--accent); border: 0; border-radius: 8px; cursor: pointer; 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; } 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); } .hint { margin: 6px 2px 0; font-size: 12px; color: var(--muted); }
.msg { min-height: 1.4em; margin-top: 12px; font-size: 14px; } .msg { min-height: 1.4em; margin-top: 12px; font-size: 14px; }
.msg.ok { color: var(--ok); } .msg.ok { color: var(--ok); }
@@ -71,24 +75,78 @@
<dt>Memory</dt><dd id="memory"></dd> <dt>Memory</dt><dd id="memory"></dd>
<dt>Content</dt><dd id="content"></dd> <dt>Content</dt><dd id="content"></dd>
</dl> </dl>
<details id="raw-box" hidden><summary id="raw-title">Raw memory</summary><pre class="mono" id="raw"></pre></details>
<form id="form"> <form id="form">
<input id="text" placeholder="https://buildfor.life or any text" autocomplete="off" autofocus> <input id="text" placeholder="https://buildfor.life or any text" autocomplete="off" autofocus>
<button id="read" type="button" class="secondary" disabled>Read</button>
<button id="program" type="submit" disabled>Program</button> <button id="program" type="submit" disabled>Program</button>
</form> </form>
<p class="hint">URLs (https://, www., tel:, mailto:, geo: …) are written as a link, anything else as plain text.</p> <p class="hint">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.</p>
<div class="msg" id="msg" role="status"></div> <div class="msg" id="msg" role="status"></div>
</main> </main>
<script> <script>
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
let busy = false, canWrite = false; let busy = false, canWrite = false, present = false;
function setStatus(level, text) { function setStatus(level, text) {
$("dot").className = "dot " + level; $("dot").className = "dot " + level;
$("status").textContent = text; $("status").textContent = text;
} }
function setMsg(level, text) {
$("msg").className = "msg " + level;
$("msg").textContent = text;
}
// One line per 4-byte page: page number, hex bytes, printable ASCII.
function pageDump(raw) {
const lines = [];
for (let i = 0; i < raw.length; i += 4) {
const page = raw.slice(i, i + 4);
const hex = page.map(b => b.toString(16).toUpperCase().padStart(2, "0")).join(" ");
const ascii = page.map(b => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : ".")).join("");
lines.push(`${String(i / 4).padStart(3)} ${hex.padEnd(11)} ${ascii}`);
}
return lines.join("\n");
}
function fillText(t) {
const r = t.records[0];
return r && (r.kind === "URL" || r.kind === "Text") ? r.value : null;
}
function renderTag(t) {
$("uid").textContent = t.uid;
$("kind").textContent = t.kind;
$("memory").textContent = t.capacity != null
? `${t.used ?? "?"} of ${t.capacity} bytes used${t.writable ? "" : ", read-only"}`
: "—";
const c = $("content");
c.replaceChildren();
t.records.forEach((r, i) => {
if (i) c.append(document.createElement("br"));
c.append(`${r.kind}: `);
if (r.kind === "URL" && /^https?:\/\//i.test(r.value)) {
c.append(Object.assign(document.createElement("a"), { href: r.value, target: "_blank", textContent: r.value }));
} else {
c.append(r.value);
}
});
if (!t.records.length && t.formatted && !t.read_failed) c.append("empty");
if (t.note) {
if (t.records.length) c.append(document.createElement("br"));
c.append(Object.assign(document.createElement("span"), { className: "note", textContent: t.note }));
}
$("raw-box").hidden = !t.raw.length;
$("raw-title").textContent = `Raw memory (${t.raw.length / 4} pages)`;
const dump = pageDump(t.raw);
if ($("raw").textContent !== dump) $("raw").textContent = dump;
}
function render(s) { function render(s) {
$("tag").hidden = s.state !== "card"; present = s.state === "card";
$("tag").hidden = !present;
if (!present) $("raw-box").hidden = true;
canWrite = false; canWrite = false;
if (s.state === "no_reader" || s.state === "error") { if (s.state === "no_reader" || s.state === "error") {
setStatus("err", s.message); setStatus("err", s.message);
@@ -98,74 +156,74 @@
const t = s.tag; const t = s.tag;
canWrite = t.formatted && t.writable && t.capacity != null && !t.note; canWrite = t.formatted && t.writable && t.capacity != null && !t.note;
setStatus(canWrite ? "ok" : "warn", s.reader + " — tag present"); setStatus(canWrite ? "ok" : "warn", s.reader + " — tag present");
$("uid").textContent = t.uid; renderTag(t);
$("kind").textContent = t.kind;
$("memory").textContent = t.capacity != null
? `${t.used ?? "?"} of ${t.capacity} bytes used${t.writable ? "" : ", read-only"}`
: "—";
const c = $("content");
c.replaceChildren();
if (t.note) {
c.append(Object.assign(document.createElement("span"), { className: "note", textContent: t.note }));
} else if (!t.records.length) {
c.textContent = "empty";
} else {
t.records.forEach((r, i) => {
if (i) c.append(document.createElement("br"));
c.append(`${r.kind}: `);
if (r.kind === "URL" && /^https?:\/\//i.test(r.value)) {
c.append(Object.assign(document.createElement("a"), { href: r.value, target: "_blank", textContent: r.value }));
} else {
c.append(r.value);
}
});
}
} }
$("program").disabled = busy || !canWrite; $("program").disabled = busy || !canWrite;
$("read").disabled = busy || !present;
}
async function refresh() {
try {
render(await (await fetch("/api/status")).json());
} catch {
setStatus("err", "Cannot reach the local NFC service — is nfc-writer-web running?");
$("tag").hidden = $("raw-box").hidden = true;
$("program").disabled = $("read").disabled = true;
}
} }
async function poll() { async function poll() {
if (!busy) { if (!busy) await refresh();
try {
render(await (await fetch("/api/status")).json());
} catch {
setStatus("err", "Cannot reach the local NFC service — is nfc-writer.exe running?");
$("tag").hidden = true;
$("program").disabled = true;
}
}
setTimeout(poll, 800); setTimeout(poll, 800);
} }
$("form").addEventListener("submit", async e => { // Run one reader job: disable the buttons, show progress on `button`, then refresh the status.
e.preventDefault(); async function job(button, label, url, body, done) {
if (busy || !canWrite) return;
busy = true; busy = true;
$("program").disabled = true; $("program").disabled = $("read").disabled = true;
$("program").textContent = "Programming…"; const idle = button.textContent;
$("msg").className = "msg"; button.textContent = label;
$("msg").textContent = ""; setMsg("", "");
try { try {
const res = await fetch("/api/program", { const res = await fetch(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: $("text").value }), body: JSON.stringify(body),
}); });
const body = await res.json(); const reply = await res.json();
if (res.ok) { if (res.ok) done(reply);
$("msg").className = "msg ok"; else setMsg("err", reply.error || `Error ${res.status}`);
$("msg").textContent = `✓ Wrote ${body.record.kind}: ${body.record.value} (${body.bytes} of ${body.capacity} bytes, verified)`;
} else {
$("msg").className = "msg err";
$("msg").textContent = body.error || `Error ${res.status}`;
}
} catch (err) { } catch (err) {
$("msg").className = "msg err"; setMsg("err", "Request failed: " + err);
$("msg").textContent = "Request failed: " + err;
} }
busy = false; busy = false;
$("program").textContent = "Program"; button.textContent = idle;
try { render(await (await fetch("/api/status")).json()); } catch {} await refresh();
}
$("form").addEventListener("submit", e => {
e.preventDefault();
if (busy || !canWrite) return;
job($("program"), "Programming…", "/api/program", { text: $("text").value }, r =>
setMsg("ok", `✓ Wrote ${r.record.kind}: ${r.record.value} (${r.bytes} of ${r.capacity} bytes, verified)`));
});
$("read").addEventListener("click", () => {
if (busy || !present) return;
job($("read"), "Reading…", "/api/read", {}, t => {
renderTag(t);
const n = t.records.length, fill = fillText(t);
if (fill !== null) {
$("text").value = fill;
setMsg("ok", `✓ Read ${t.uid}: ${n} record${n === 1 ? "" : "s"}. The first one is now in the field: put another tag on and press Program to copy it.`);
} else if (n) {
setMsg("ok", `✓ Read ${t.uid}: ${n} record(s), none of them a link or text.`);
} else if (t.note) {
setMsg("err", `Read ${t.uid} (${t.kind}): UID only, no NDEF content.`);
} else {
setMsg("ok", `✓ Read ${t.uid}: the tag is empty.`);
}
});
}); });
poll(); poll();