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.
396 lines
14 KiB
Rust
396 lines
14 KiB
Rust
//! Desktop GUI: same reader logic as the web version, in a native window (Windows, macOS, Linux).
|
|
|
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
|
|
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;
|
|
|
|
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. \
|
|
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: 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<Job>,
|
|
}
|
|
|
|
fn main() -> eframe::Result {
|
|
let options = eframe::NativeOptions {
|
|
viewport: egui::ViewportBuilder::default()
|
|
.with_title("NFC tag writer")
|
|
.with_inner_size([560.0, 390.0])
|
|
.with_min_inner_size([420.0, 320.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())))
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
/// 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(job) => Some(job),
|
|
Err(mpsc::RecvTimeoutError::Timeout) => None,
|
|
Err(mpsc::RecvTimeoutError::Disconnected) => return,
|
|
};
|
|
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 = None;
|
|
}
|
|
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 start(&mut self, kind: JobKind) {
|
|
let mut s = self.shared.lock().unwrap_or_else(|e| e.into_inner());
|
|
if s.busy.is_some() {
|
|
return;
|
|
}
|
|
s.busy = Some(kind);
|
|
s.result = None;
|
|
let _ = self.jobs.send(match kind {
|
|
JobKind::Read => Job::Read,
|
|
JobKind::Program => Job::Program(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| {
|
|
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);
|
|
}
|
|
});
|
|
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 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| {
|
|
egui::ScrollArea::vertical()
|
|
.auto_shrink(false)
|
|
.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;
|
|
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(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);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|