| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| app | ||
| cmd/skannerd | ||
| deploy | ||
| docs | ||
| internal | ||
| scripts | ||
| .dockerignore | ||
| .editorconfig | ||
| .gitignore | ||
| android_testers.txt.example | ||
| CLAUDE.md | ||
| go.mod | ||
| go.sum | ||
| mage.go | ||
| magefile.go | ||
| README.md | ||
Skanner
Scan customer QR codes at a show — one person or a whole group at a time — and attach a dictated or typed note to each group. Scans reach the server as they happen; at the end of a show the server reconciles them into attendee records.
app/ Flutter client for iOS and Android
cmd/ skannerd — the server binary
internal/ JSON API, web admin, store, badge parsing
deploy/ Dockerfile, compose stack, Traefik labels
docs/ architecture, data model, API contract, deployment, app builds
Live at https://skanner.lbs.sh.
Quick start — backend
mage run # serves on :8080 against a throwaway ./tmp/dev.db
On first boot with no SKANNER_ADMIN_PASSWORD_HASH set, the server invents an
admin password and logs it once. Open http://localhost:8080/admin/, create a
show, activate it, and issue a device key on the Devices page.
mage lint # go vet + staticcheck + flutter analyze
mage test # go test -race + flutter test
mage smoke # end-to-end run against a real server process
./bin/skannerd device add "Front desk" # issue a key from the CLI instead
mage adminHash # generate a permanent admin hash
mage -l lists everything.
Quick start — app
mage app:get # flutter pub get
cd app && flutter run # on a real device: the camera is the point
mage app:android # release APK into releases_android/
mage app:ios # signed App Store IPA
Release to TestFlight
$EDITOR app/pubspec.yaml # bump the build: 0.1.0+4 → 0.1.0+5
(cd app && flutter analyze && flutter test)
mage app:validate app:upload
Four things make the difference between that working and costing you a build:
- Build numbers are permanent. Once App Store Connect has seen
0.1.0 (5), that pair is rejected forever.app:validateruns Apple's real ingest checks and reports the same ITMS errors while spending nothing, which is why it comes first. - Test before validating, not after. Validation checks the package against store rules and knows nothing about whether the app works. A build that destroys data validates perfectly.
- One invocation, not two.
mage app:validate app:uploadbuilds the archive once —mg.Depsdedupes within a run — and mage stops at the first failing target, so a failedVERIFYaborts before altool is handed anything and the build number survives. - Bump in
pubspec.yaml. It drives the AndroidversionCodetoo.SKANNER_BUILD_NUMBERinapp/ios/appstore.envis the escape hatch for re-uploading without touching the pubspec, not the normal path.
Credentials live in the gitignored app/ios/appstore.env, and the matching
AuthKey_<id>.p8 in ~/.appstoreconnect/private_keys — where altool finds it
by filename. Rename it and authentication fails with an error that never
mentions the filename.
Internal TestFlight testers skip Beta App Review entirely and get a build within minutes of processing; external testers need review on the first build of a version.
docs/APP_BUILD.md has the detail: signing, the four iOS usage strings and why the photo-library one must not be deleted, and the two Android manifest entries that fail silently if dropped.
How it fits together
app (Flutter) server (Go, one binary)
───────────── ───────────────────────
camera → scan → local SQLite ──▶ POST /api/v1/sync ──▶ SQLite (modernc)
(source of truth, idempotent upsert on shows · scan_groups
survives no network) client-owned UUIDv7 scans · note_templates
│ devices · customers
outbox worker ◀── GET /api/v1/show/active
(retry + backoff) (which show, which note form)
/admin/* operator UI
The badge QR carries the attendee's record inline, so both halves parse it —
internal/badge on the server and app/lib/domain/attendee.dart on the phone,
against shared fixtures. The operator sees a name the instant they scan, and
end-of-show reconciliation needs no network.
The app owns the primary keys. That one decision is what makes the offline path safe: a re-sent batch upserts onto the same rows instead of duplicating them, so a phone can lose its connection mid-sync and simply try again.
Further reading: architecture · data model · API · deployment · app builds · handoff