//! 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, 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, } 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 { 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(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, 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); } }); }); } }