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

+206 -93
View File
@@ -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<Status>,
busy: bool,
result: Option<Result<Programmed, String>>,
busy: Option<JobKind>,
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 {
text: String,
shared: Arc<Mutex<Shared>>,
jobs: mpsc::Sender<String>,
jobs: mpsc::Sender<Job>,
}
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<String>, shared: Arc<Mutex<Shared>>, ctx: egui::Context) {
/// Text a read puts into the input field: the first link or text record.
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();
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<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 {
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);
}
});
});
}
}