The agent is a Go module, and the binary in cmd/davi-nfc-agent is an ordinary
program built from packages this repository exports. To change what it does,
write your own main.go against those packages.
go get github.com/dotside-studios/davi-nfc-agent
Pin the version you build against. These packages follow the agent’s releases and do not yet carry a compatibility guarantee.
This is the shipped binary with its flag set replaced by fixed options: TLS, pairing, the WebSocket API, the control center and the tray.
package main
import (
"io"
"log"
"net/http"
"os"
"github.com/dotside-studios/davi-nfc-agent/agent"
"github.com/dotside-studios/davi-nfc-agent/agent/console"
"github.com/dotside-studios/davi-nfc-agent/agent/pairingplugin"
"github.com/dotside-studios/davi-nfc-agent/agent/serverplugin"
"github.com/dotside-studios/davi-nfc-agent/agent/trustplugin"
"github.com/dotside-studios/davi-nfc-agent/agent/tray"
"github.com/dotside-studios/davi-nfc-agent/logbuf"
"github.com/dotside-studios/davi-nfc-agent/nfc"
"github.com/dotside-studios/davi-nfc-agent/nfc/multimanager"
"github.com/dotside-studios/davi-nfc-agent/nfc/pcsc"
"github.com/dotside-studios/davi-nfc-agent/nfc/remotenfc"
"github.com/dotside-studios/davi-nfc-agent/server"
"github.com/dotside-studios/davi-nfc-agent/server/clientserver"
"github.com/dotside-studios/davi-nfc-agent/server/listener"
tlspkg "github.com/dotside-studios/davi-nfc-agent/secure/tls"
)
func main() {
opts := agent.DefaultOptions()
// The console reads its log from this ring. Install names it to the
// packages that report on their own channels, and Options hands the same
// ring to the agent, for its own log and its plugins'.
opts.Logs = logbuf.New(logbuf.DefaultCapacity)
logbuf.Install(opts.Logs)
// The driver serving phones. What it scans and what its devices hold reach
// the agent through the manager below; its endpoint is mounted with the
// server plugin, so the agent names no device protocol itself.
devices := remotenfc.NewManager(remotenfc.DeviceTimeout)
// Hardware readers and phones behind one manager, which the agent opens
// its reader from.
backends := multimanager.NewMultiManager(
multimanager.ManagerEntry{Name: nfc.ManagerTypeHardware, Manager: pcsc.NewManager()},
multimanager.ManagerEntry{Name: nfc.ManagerTypeSmartphone, Manager: devices},
)
// The certificate this agent manages for itself, under the config
// directory the agent resolves. Provisioning it is the program's: the
// agent neither serves it nor hands out its authority.
if opts.ConfigDir == "" {
opts.ConfigDir = agent.DefaultConfigDir(opts.Info.OrDefault().DirName)
}
certs, err := tlspkg.Provision(opts.ConfigDir, opts.InstallCA)
if err != nil {
log.Fatal(err)
}
opts.CertFile, opts.KeyFile, opts.PublicKeyPin = certs.CertFile, certs.KeyFile, certs.PublicKeyPin
// The paired-device manager over the backends: the credential store, the
// pairing machinery, and the check that admits a device. It is what the
// agent holds, so this build cannot have the readers without the policy
// deciding who reaches them. Leave it out and every device is admitted.
paired := pairing.New(backends, pairing.Options{
ConfigDir: opts.ConfigDir,
CA: certs.Manager,
AppName: opts.Info.OrDefault().DisplayName,
PublicKeyPin: func() string { return certs.PublicKeyPin },
})
rt, err := agent.Setup(opts, backends)
if err != nil {
log.Fatal(err)
}
// What it admits on, and what a pairing device is told to connect to, now
// that the agent holds them. All read per use.
paired.UseSecret(rt.Agent.APISecret)
paired.Require(rt.Agent.RequirePairedDevice)
paired.UsePort(rt.Agent.DevicePort)
// The tray entry that installs the local authority, so browsers on this
// machine accept the agent.
trust := &trustplugin.Plugin{Manager: certs.Manager}
// The listener and everything on it. Setup builds no listener; the program
// decides what this agent serves. Setup resolved which certificate to
// serve; Certificates is what rebinds the listener when it is reissued.
servers := &serverplugin.Plugin{
Config: listener.Config{CertFile: certs.CertFile, KeyFile: certs.KeyFile},
Certificates: certs.Manager,
AllowedOrigins: server.ParseAllowedOrigins(opts.AllowedOrigins),
}
// The two halves of /ws, both declared here. The paired-device manager
// decides who is admitted and the agent what is allowed; each protocol
// decides what its own side may say.
servers.ServeMode = map[string]http.Handler{
server.ModeClient: clientserver.New(clientserver.Config{
APISecret: rt.Agent.APISecret,
OriginPolicy: servers.OriginPolicy(),
TokenVerifier: paired.TokenVerifier(),
Tags: rt.Agent,
AllowTagModification: rt.Agent.TagModificationAllowed,
Scans: &rt.Agent.Events().Tag,
ReaderStatus: &rt.Agent.Events().Reader,
}),
// The driver serves the protocol; Admit decides who gets that far, and
// names the device it admitted so the driver registers it under the
// identity it paired with. Mount the driver bare and every device is
// admitted under an identity of the driver's own minting.
server.ModeDevice: paired.Admit(devices.Handler(remotenfc.ServerOptions{
CheckOrigin: servers.CheckOrigin(),
AllowTagModification: rt.Agent.TagModificationAllowed,
PublicKeyPin: rt.Agent.PublicKeyPin,
})),
}
// Pairing: a listener of its own, and the tray entries that hand out its
// address and PIN. The machinery belongs to the paired-device manager; this
// plugin runs its listener and shows its PIN.
pairing := pairingplugin.New(paired, opts.BootstrapPort)
app := tray.New(rt)
// The control center, served from the same listener and listed with the
// other addresses. A -tags nowebui build has none, and Endpoints is empty,
// so this program needs no build tag of its own.
c := console.New(console.Config{
Agent: rt.Agent,
Logs: rt.Logs,
Servers: servers,
Pairing: paired,
BootstrapPort: opts.BootstrapPort,
Certificates: certs.Manager,
Quit: app.Quit,
})
servers.Add(c.Endpoints()...)
// The server goes on first: it publishes the listener the rest mount on,
// and plugins are activated in the order they were added, which is also the
// order their entries appear in the tray.
if err := rt.Agent.Plugins.Add(servers, pairing, trust); err != nil {
log.Fatal(err)
}
app.Run()
}
agent.Setup performs the work the flags imply: it resolves the config
directory, loads or generates the TLS certificate and the API secret, and reads
the paired devices. It returns an *agent.Runtime holding the configured agent,
the certificate manager, the log ring, the reader path to open and the origins
the flags named, for the server plugin to serve behind.
The listener, the pairing server and the control center are plugins the program
registers, not part of Setup. An agent with none of them drives the reader and
serves no HTTP, which is a valid build. Binding happens at start, so routes can
be declared before the port exists. See Plugins.
The certificate is tls.Provision’s, called before Setup with the config
directory the agent will use. It reports the *tls.Manager, the pair a listener
serves, and the public key pin, which goes on Options.PublicKeyPin so the
agent hands it to devices. Setup provisions nothing: what serves a
certificate, hands out its authority and offers to install it is the program’s.
Each plugin takes the narrow part it needs: serverplugin.Plugin.Config the
files and serverplugin.Plugin.Certificates the reissue signal,
pairingplugin.New the authority a pairing device is given. A build serving a
certificate provisioned elsewhere names the pair on Options and leaves
Certificates nil, there being nothing to reissue.
trustplugin.Plugin wraps the same manager for the one job the others do not do:
the tray entry that installs the local authority, hidden once there is nothing
left to install. Leave Manager nil and the plugin is inert.
The console takes the manager and the gate themselves, not these two plugins: both exist before it does, and what it needs from them is the certificate and the credentials rather than the tray entries. It follows the server plugin instead, which builds its listener when it activates.
Pairing is pairing.Gate: the credential store, the endpoint that issues into
it, the check that admits on it, and the revocation that ends a session when one
is withdrawn. It is not a manager and is not in the manager tree. What it needs
of a backend is pairing.Sessions, one method, so a revocation can reach a
session already open; pass the manager tree, or nil for backends holding none.
pairingplugin.Plugin runs the gate’s cleartext listener and owns pairing’s
tray entries: the address, the PIN, and the paired devices to list and revoke.
pairingplugin.New(paired, port). Mount /pair from the gate, not the plugin:
paired.PairHandler() exists whatever the build does about the cleartext
listener, so omitting the plugin leaves devices pairing over /pair with no CA
download and no menu entries, and the console handed nil. For the listener
without the menu entries, register
pairingplugin.NewServer(paired.PairingServer(), port) with ctx.Use or a
serverplugin.Endpoint.
The agent holds none of this. It neither stores credentials nor reports them, so
agent links no third-party package at all.
Omit the paired-device manager and the build pairs nobody and admits
everyone: hand backends to Setup and mount the device endpoint bare. That is
what a build reached only over a trusted transport, and every test, wants. A
bare remotenfc endpoint mints an identity per connection, so devices still
register, just under no credential.
The NFC backend is Setup’s second argument, which is why every package beneath
cmd builds without one. A manager reports what its devices scan through
nfc.TagReporter and answers for the tags they hold through nfc.TagHolder,
both optional, so the agent subscribes to the manager it was given rather than
being handed the driver. multimanager implements both by fanning its children
in. Serving those devices is not the manager’s business: the driver’s endpoint
goes on the server plugin as ServeMode[server.ModeDevice], built from what the
agent answers, and a build that mounts none serves its own readers alone.
Flags and the standard logger belong to the program. Registering flags writes to
flag.CommandLine, which would collide with the flags of anything embedding the
agent, so the shipped command adds its own flag set on top of Options in
cmd/davi-nfc-agent/flags.go and installs the
log ring itself, as above.
agent.New is the alternative to Setup, for a program with its own
configuration: it takes an agent.Config and builds the agent from values you
already hold, leaving the certificate, secret and store loading to you. Either
way the configuration is fixed once the agent exists and is read back through
methods, so nothing can rebind the port or withdraw the pairing requirement
behind the running servers. The preferences that may legitimately change while
running have methods of their own: SetReaderMode, SetCardTypeFilter, SetPinnedDevice,
SetDevicePort, SetRequirePairedDevice and SetReaderFeedback. Nothing
persists them: a change lasts as long as the agent runs, and what it starts with
comes from agent.Config.
A plugin is a value with one method. It is handed an agent.AgentContext once,
before the agent starts, and registers whatever it wants the agent to run.
type BackupPlugin struct {
Every time.Duration
}
func (p *BackupPlugin) Activate(ctx agent.AgentContext) error {
backups := &backupWorker{every: p.Every, dir: ctx.ConfigDir()}
ctx.Systray.Add("Back Up Now", traymenu.OnClick(backups.Run))
return ctx.Use(backups)
}
rt.Agent.Plugins.Add(&BackupPlugin{Every: time.Hour})
A build’s plugins are what it imports, fixed at compile time, so one left out
takes its dependencies with it, the same way nfc/pcsc and the tray do.
The context carries what a plugin needs to wire itself in:
ctx.Agent |
The agent, for its configuration and for what it can be told to do |
ctx.Events |
What the agent reports: see Following the agent |
ctx.Use(c) |
Registers an agent.Component, started and stopped with the agent |
ctx.Systray |
The menu the plugin’s entries go on |
ctx.Serve(srv) |
Publishes the listener the agent serves from |
ctx.Mount(pattern, h) |
Adds a route to it |
ctx.Logger(), ctx.Info(), ctx.ConfigDir(), ctx.Logs() |
The plugin’s log channel, and the agent’s identity, config directory and log ring |
ctx.Systray is the top level of the tray’s own menu, so a plugin’s entry looks
no different from one the tray declared itself. Entries land where the tray
activated the plugins, since a menu item always goes to the end of its parent. A
plugin with more than one entry groups them under a submenu of its own, with
ctx.Systray.Section("Backups").
ctx.Systray is never nil. A headless agent hands over a menu that draws
nothing, so a plugin can add its entries without checking for a tray.
A plugin has no Deactivate. Anything with a lifetime is a Component, which
the agent starts once the reader and the servers are up and stops before taking
them down again.
Plugins are activated once, in the order they were added, before anything is
opened or bound. The tray does it as it draws its menu, so the entries land on
the real one; Agent.Start does it if nothing else has. Adding a plugin after
that is refused.
Activation is also where a plugin publishes what the agent is served from and mounts its routes, so a listener’s port and address are worth reading only once it has happened.
// A headless build with no tray to draw their entries on.
if err := rt.Agent.Activate(nil); err != nil {
log.Fatal(err)
}
A plugin that returns an error fails the agent’s start, naming the plugin, and the same failure is reported by every start afterwards.
Three plugins ship with the agent. A build registers what it wants; registering none leaves an agent that drives the reader and serves nothing.
| Plugin | Owns |
|---|---|
serverplugin.Plugin |
The listener, everything mounted on it, the origin allowlist, and the tray’s Server URLs and Allowed Origins submenus |
pairingplugin.Plugin |
The pairing server’s own cleartext listener, and the entries showing its address and PIN |
trustplugin.Plugin |
The entry that installs the local certificate authority |
trust := &trustplugin.Plugin{Manager: certs.Manager}
servers := &serverplugin.Plugin{
Config: listener.Config{CertFile: certs.CertFile, KeyFile: certs.KeyFile},
Certificates: certs.Manager,
AllowedOrigins: server.ParseAllowedOrigins(opts.AllowedOrigins),
}
pairing := pairingplugin.New(paired, 9472)
// Pairing issues a durable credential and the key pin a device recognises this
// agent by, so it is served from the listener that already serves the
// certificate that pin covers. Port 9472 stays cleartext: it hands out the
// certificate authority to a device that does not trust that certificate yet.
servers.Add(serverplugin.Endpoint{
Name: "pairing",
Pattern: "/pair",
Handler: paired.PairHandler(),
})
rt.Agent.Plugins.Add(servers, pairing, trust)
The server plugin goes on first. It publishes the listener with ctx.Serve,
which is what backs ctx.Mount for every plugin registered after it, and an
agent.Mounter is one method wide so the agent never names a server type.
What goes on that listener is an serverplugin.Endpoint: a route, something with a
lifetime, a menu entry, or any combination.
servers.Add(serverplugin.Endpoint{Name: "webhooks", Pattern: "/hooks/", Handler: hooks})
servers.Add(serverplugin.Endpoint{Name: "queue drain", Component: drain})
The plugin mounts what the agent is reached on first and reserves it: /ws,
where devices and clients both connect, and /health with /api/v1/health
beside it. An endpoint on one of those paths fails the start, as two endpoints
on one path do, rather than leaving the mux to decide.
/ws routes a connection by the mode it declares, and ServeMode is the whole
answer to what a connection reaches. Nothing is mounted for you: a build
declares what it serves, browser clients included.
servers.ServeMode = map[string]http.Handler{
server.ModeClient: clientserver.New(clientserver.Config{ ... }),
server.ModeDevice: devices.Handler(remotenfc.ServerOptions{ ... }),
}
A build that names no client server serves no clients, the same way one that names no device endpoint serves no devices. What is named lives as long as the plugin rather than as long as a run, so a client stays connected across a stop and start of the agent and receives again once it runs.
The allowlist of browser origins is the plugin’s too, since it decides which
upgrades it admits. AllowedOrigins seeds it and the store persists under the
config directory; Origins supplies one loaded elsewhere. servers.CheckOrigin() is the
same decision for a handler mounted beside it, and servers.OriginPolicy() the
same for anything taking a server.OriginPolicy. Both resolve per request, so
they can be handed over before the plugin has a store and follow an origin
allowed while the agent runs.
The credential check for a device endpoint is not the plugin’s. It belongs to
whatever owns the credentials, which is pairing.Gate: wrap the endpoint
in paired.Admit(...) at the mount. Its policy comes from UseSecret,
Require and AllowLoopback, each read per request, so rotating the secret,
withdrawing the paired-device requirement or changing the bypass needs nothing
rebuilt. See The loopback bypass for what
AllowLoopback admits.
The clients connected right now are reported through the plugin:
servers.ClientCount(), servers.Clients() and servers.DisconnectClient(id)
answer from whatever is under server.ModeClient, when it is a
*clientserver.Server, and report nothing when it is not.
servers.Events().Clients follows the count and takes a subscriber before the
plugin activates, which is when a console is built. servers.Events().Origins
does the same for the allowlist, carrying the allowed and refused origins and
the session-wide bypass.
A build that registers no server plugin serves no HTTP and runs no client
server, which is what a program driving the readers directly wants. It still
gets every scan through Agent.Events().
The listener is bound by a component the plugin registers, so it comes up once
the agent is serving and goes down before it. Give it Certificates and a
reissued certificate rebinds it on its own; leave it nil for one that never
changes underneath.
pairingplugin.Plugin and trustplugin.Plugin tolerate being nil, so a build that registers
neither hands nil to the console and it reports both as unavailable. The
per-field details are on the types themselves: go doc serverplugin.Plugin,
serverplugin.Endpoint, pairingplugin.New, trustplugin.Plugin.
The pairing entries follow the server, so rotating the PIN from the menu or from
the console relabels both. The trust entry is shown only while there is
something to install and hides once there is not, and Install blocks on the
operating system’s password prompt: the menu calls it off the dispatch
goroutine, and a program calling it directly should do the same.
The console is two endpoints of the server plugin, so it is served from the agent’s port and listed with the other addresses:
c := console.New(console.Config{
Agent: rt.Agent,
Logs: rt.Logs,
Servers: servers,
Pairing: paired,
BootstrapPort: 9472,
Certificates: certs.Manager,
Quit: app.Quit,
})
servers.Add(c.Endpoints()...)
The three plugins are what the console reports on and acts through: the address
it hands out is the listener’s, the PIN it rotates is the pairing server’s, and
the authority it installs is the trust plugin’s, so a tray entry stays in step
with the same action taken from a page. console.New also connects to
Events().Any, so every change the agent reports redraws an open page. Under
-tags nowebui there is no console compiled in and Endpoints is empty, so a
program needs no build tag of its own.
Quit is what the console’s quit control calls, since ending the program
belongs to whoever owns it. Everything else the console does goes to the agent,
and the tray redraws from the agent’s events rather than being told.
A build that keeps the agent’s identity also keeps its configuration directory,
and two programs sharing that directory share their certificates and paired
devices. Options.Info, or Config.Info when building the agent directly,
replaces the identity for the whole tree:
opts := agent.DefaultOptions()
opts.Info = buildinfo.Info{
Name: "gate-agent",
DirName: "gate-agent",
DisplayName: "Gate Reader",
Version: "2.1.0",
}
That name follows through everywhere the agent presents itself: the
configuration directory, the log banner, the control center header, the tray
tooltip, the pairing pages and the iOS configuration profile, and the mDNS
service the devices look for. Fields left blank fall back to the agent’s own, so
overriding only DirName is enough to stop two builds colliding on disk.
| Package | Contents |
|---|---|
agent |
The agent, and the plugins the shipped build registers: the listener, pairing and the certificate |
agent/console |
The control center: the privileged API, the embedded frontend, and the adapter onto the agent |
agent/tray |
The system tray |
nfc |
The reader supervisor, tag drivers, NDEF encoding and decoding |
nfc/pcsc |
The PC/SC hardware backend |
nfc/remotenfc |
Phones and WebNFC browsers: the device protocol, its WebSocket endpoint, the sessions and the tags behind them |
nfc/multimanager |
Several backends behind one nfc.Manager |
server |
The bridge between tag sources and clients, and the credential checks both endpoints are gated by |
server/clientserver |
The client WebSocket endpoint, and what it performs on the tag a request names |
server/listener |
One HTTP listener: a port, a mux of what was mounted on it, TLS and mDNS |
server/wsconn |
Write-safe WebSocket wrapper shared by the servers and the device driver |
protocol |
The wire vocabulary both protocols share: the message envelope, the error taxonomy, NDEF input |
traymenu |
Declarative tray menus, with no toolkit behind them |
event |
The signal the agent and the menus publish their callbacks on |
clipboard |
Copying text to the system clipboard |
traymenu/fynetray |
The real tray, on fyne.io/systray |
tls, logbuf |
Certificates, and the log ring with the named channels that write into it |
e2e |
Tests only: an agent wired as on this page, driven over its protocols |
Dependencies run in one direction. agent/console and agent/tray import
agent; neither is imported by it, and no package below cmd imports both.
Two properties follow:
agent depends on no GUI toolkit. fyne.io/systray arrives only with
agent/tray.agent depends on no NFC backend. nfc/pcsc arrives only where it is
imported, which in the shipped binary is cmd/davi-nfc-agent/main.go.Omitting the tray or the hardware backend therefore drops imports without changing the agent.
Omitting the tray and the console leaves a service that reads cards and serves
the WebSocket API. agent.DefaultOptions supplies the values the flags would
otherwise provide.
package main
import (
"log"
"os"
"os/signal"
"syscall"
"github.com/dotside-studios/davi-nfc-agent/agent"
"github.com/dotside-studios/davi-nfc-agent/agent/serverplugin"
"github.com/dotside-studios/davi-nfc-agent/nfc/pcsc"
"github.com/dotside-studios/davi-nfc-agent/server"
"github.com/dotside-studios/davi-nfc-agent/server/clientserver"
"github.com/dotside-studios/davi-nfc-agent/server/listener"
)
func main() {
opts := agent.DefaultOptions()
opts.ConfigDir = "/var/lib/davi-nfc"
opts.AllowedOrigins = "console.example.com"
opts.DevicePort = 9470
rt, err := agent.Setup(opts, pcsc.NewManager())
if err != nil {
log.Fatal(err)
}
// The listener, serving browser clients on /ws and the health checks beside
// it. Leave it out for a service that reads cards and serves no HTTP. Setup
// resolved the certificate; blank leaves the listener serving plain HTTP.
servers := &serverplugin.Plugin{
Config: listener.Config{CertFile: certs.CertFile, KeyFile: certs.KeyFile},
Certificates: certs.Manager,
AllowedOrigins: server.ParseAllowedOrigins(opts.AllowedOrigins),
}
servers.ServeMode = map[string]http.Handler{
server.ModeClient: clientserver.New(clientserver.Config{
APISecret: rt.Agent.APISecret,
OriginPolicy: servers.OriginPolicy(),
TokenVerifier: paired.TokenVerifier(),
Tags: rt.Agent,
AllowTagModification: rt.Agent.TagModificationAllowed,
Scans: &rt.Agent.Events().Tag,
ReaderStatus: &rt.Agent.Events().Reader,
}),
}
if err := rt.Agent.Plugins.Add(servers); err != nil {
log.Fatal(err)
}
// An empty device path selects the first reader, and waits if none is
// attached yet.
if err := rt.Agent.Start(rt.DevicePath); err != nil {
log.Fatal(err)
}
// Shutdown stops the agent and then closes the manager. Stop alone leaves
// the manager open, since the agent can be started against it again.
defer rt.Agent.Shutdown()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
}
A server plugin with no endpoints serves the agent’s own routes: /ws and the
two health checks, with the root falling back to a plain-text banner. Building
no pairing server means no device can pair, so a phone authenticates with the
API secret instead of a credential of its own. -tags nowebui additionally
removes the console from the binary.
Passing only the remote manager keeps nfc/pcsc out of the build entirely. The
result requires no libpcsclite at build or run time and cross-compiles to any
target.
manager := remotenfc.NewManager(remotenfc.DeviceTimeout)
rt, err := agent.Setup(agent.DefaultOptions(), manager)
if err != nil {
log.Fatal(err)
}
rt.Agent.Plugins.Add(&serverplugin.Plugin{
Config: listener.Config{CertFile: certs.CertFile, KeyFile: certs.KeyFile},
Certificates: certs.Manager,
})
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/your-agent
Phones and WebNFC browsers connect over the Device API and report the tags they scan, so such a build is complete for deployments where every reader is a phone.
rt.Agent.Events() is what the agent reports. Connect a handler to a signal and
it runs on every emission; the connection it returns removes it again.
| Signal | Carries |
|---|---|
State |
Each settled lifecycle transition |
Preferences |
The preferences after a change, whoever made it |
Servers |
The port the listeners are bound on, after a restart |
Reader |
The reader’s status: connected, and whether a card is on it |
Readers |
The readers that can be picked, when the set changes |
Devices |
The paired devices, after a pairing or a revocation |
Tag |
Every scan the agent broadcasts |
Any |
The kind of every change above, except scans and reader status |
State, Preferences, Servers, Readers and Devices are
event.Property: connecting calls the handler with the current value before
returning, so a subscriber draws its first frame without reading the agent
separately and cannot miss a change in between. Signal.Connect on the same
field connects without that first call, for a subscriber that wants the next
value rather than this one. Tag, Reader and Any carry traffic and have no
current value to report.
conn := rt.Agent.Events().Preferences.Connect(func(p agent.Preferences) {
log.Printf("reader is now in %s mode", p.Mode)
})
defer conn.Disconnect()
Any is for a surface that redraws rather than acts on the value, so it carries
an agent.Change naming what moved instead of the value itself. Scans and
reader status are left out of it: a page redrawing per card is not what a
subscriber to “something changed” is asking for. Subscribe to Tag and Reader
by name for those.
Handlers run on the goroutine that made the change, in the order they connected, so they must not block. Work that may take time belongs on a channel of your own. Connecting and disconnecting is safe at any time, including from inside a handler and while the agent runs.
Events().Tag carries every scan the agent broadcasts, in the order the
connected clients receive it.
package main
import (
"log"
"os"
"os/signal"
"syscall"
"github.com/dotside-studios/davi-nfc-agent/agent"
"github.com/dotside-studios/davi-nfc-agent/nfc"
"github.com/dotside-studios/davi-nfc-agent/nfc/pcsc"
)
func main() {
rt, err := agent.Setup(agent.DefaultOptions(), pcsc.NewManager())
if err != nil {
log.Fatal(err)
}
rt.Agent.Events().Tag.Connect(func(data nfc.NFCData) {
if data.Card == nil {
return
}
log.Printf("scanned %s (%s)", data.Card.UID, data.Card.Type)
})
if err := rt.Agent.Start(rt.DevicePath); err != nil {
log.Fatal(err)
}
defer rt.Agent.Shutdown()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
}
A subscriber observes rather than intercepts. The scan reaches every connected client regardless, and what the handler returns changes nothing.
The agent answers for every tag it can reach, on a reader it polls or on a device that reported one, so a plugin acts on a card without reaching for the readers behind it:
device, uid, ok := rt.Agent.TagOn("")
if ok {
_, err := rt.Agent.WriteTag(device, uid, msg, false, "check-in-42")
}
TagOn, DevicesHoldingTags, WriteTag, LockTag, TransceiveTag and
TagCapabilities are nfc.TagHolder, the same interface the client server is
given, so what a plugin can do to a tag is what a client can. An empty device
means whatever is holding a tag; naming one that is not is refused, as is any
operation while the agent is not serving.
A program that needs no WebSocket API at all can skip the agent and operate the
readers itself. nfc.Supervisor opens every reader the manager offers, so a
second one plugged in is picked up rather than ignored, and each scan names the
reader it was read on.
package main
import (
"log"
"time"
"github.com/dotside-studios/davi-nfc-agent/nfc"
"github.com/dotside-studios/davi-nfc-agent/nfc/pcsc"
)
func main() {
readers, err := nfc.NewSupervisor(pcsc.NewManager(), 5*time.Second)
if err != nil {
log.Fatal(err)
}
defer readers.Stop()
// Read-only also puts the write path out of reach, including Lock, which
// cannot be undone. It applies to every reader, including one opened later.
readers.SetMode(nfc.ModeReadOnly)
scans, stop := readers.Scans().Channel(16)
defer stop()
if err := readers.Start(); err != nil {
log.Fatal(err)
}
for data := range scans {
if data.Err != nil {
log.Printf("scan error on %s: %v", data.Device, data.Err)
continue
}
if data.Card != nil {
log.Printf("%s scanned %s (%s)", data.Device, data.Card.UID, data.Card.Type)
}
}
}
An operation names the reader it applies to, which data.Device carries:
result, err := readers.WriteMessage(data.Device, msg, nfc.WriteOptions{ExpectUID: data.Card.UID})
Naming no reader means the only one there is, and is refused once there is more than one rather than picking for you.
Both loops are testable without hardware: nfc exports NewMockManager and
NewMockTag, and nfc/nfctest provides an emulator. Construct cards with
nfc.NewCard: a nfc.Card assembled field by field has no tag behind it and
cannot be read from.
nfc.Manager, nfc.Device and nfc.Tag are interfaces, and multimanager
runs several implementations alongside one another, which is how hardware
readers and phones coexist today. Implementing them for a reader of your own is
covered in Extending NFC support.
| Build | Effect |
|---|---|
| (default) | Everything; PC/SC through goscard, without cgo |
-tags nowebui |
No control center: no /control routes, no privileged API, no tray entry, no embedded frontend |
-tags cgopcsc |
PC/SC through ebfe/scard instead, which requires cgo and libpcsclite |
CGO_ENABLED=0 |
Already the default; nothing requires cgo except the tray on macOS |
fyne.io/systray talks to Cocoa, so agent/tray, and any command that imports
it, needs cgo on macOS. Every other package builds without cgo for every
supported target, so a headless build is portable.