- 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:
1 parent
cb1b1b04c8
commit
8b95d0d5d7
10 files changed
+610
-165
No files matched your search
@@ -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()
|
||||||
@@ -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/
|
||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user