# Tiger Studio Manager
> Desktop companion for the TigerTag RFID filament-tracking ecosystem. Electron app — Vanilla JS + Firebase + NFC.
Tiger Studio Manager is an Electron desktop application for managing 3D-printing filament spools tagged with TigerTag RFID chips. It provides real-time Firestore inventory sync, live 3D printer integration for 7 brands plus Klipper and OctoPrint plugins (about 380 printer models in its catalogues, each with a picture), NFC chip read/write, hardware sensors (color sensor, scale), rack storage management, shareable wishlists and a friends system — all in a single window without a build step (no bundler).
**Positioning:** this app is one piece of the wider **TigerSystem** ecosystem, and deliberately a laboratory rather
than the destination — a demonstration of what open, documented technology allows once a spool can identify itself.
It is neutral and agnostic by design: TigerTag takes no side between filament brands, printer makers or
distributors. The protocol is open and anyone can build a different ecosystem on it; nothing in this repo is a
prerequisite for doing so.
Current version: **2.53.0** — macOS (Intel + Apple Silicon, signed & notarized), Windows, Linux.
Showcase site (11 languages, built from `site/`, deployed by `.github/workflows/pages.yml`): https://tigertag-project.github.io/TigerTag-Studio-Manager/ — downloads: https://github.com/TigerTag-Project/TigerTag-Studio-Manager/releases/latest
## Architecture
- **Entry point**: `main.js` (Electron main process)
- **Renderer**: `renderer/inventory.html` + `renderer/inventory.js` (a single ES module, ~41 000 lines, already importing ~29 sub-modules) + modular CSS in `renderer/css/`
- **Preload bridge**: `preload.js` — exposes IPC handlers to renderer via `window.electronAPI`
- **Web target (Tiger Studio Web, v2.40.0)**: the same renderer runs in a browser at `studio.tigersystem.io` (`tigersystem.io/studio` redirects there; own origin, so its own sign-in). `renderer/web-bridge.js` defines a partial browser `electronAPI` (`isWeb: true`) when preload is absent; `HAS_NATIVE` in `inventory.js` is the only "is this desktop" test (never `!!window.electronAPI`); `data-desktop-only` / `data-web-only` attributes hide UI per target (`html.is-web`). `scripts/build-web.mjs` makes the static build (app page at the folder root via ``, PWA manifest, `version.json`); the Hub (TigerTag_Hub) builds the published latest release into `/studio` and `.github/workflows/web-sync.yml` keeps web and desktop on the same version; web-only fixes ship as `X.Y.Z · web N` via `npm run web:hotfix` (`docs/WEB_HOTFIX.md`). On https the bridge refuses insecure `ws:`/`http:` connections (no LAN from the web). Full guide: `docs/WEB_DEPLOYMENT.md`.
- **NFC**: `services/nfc-process.js` runs as a `utilityProcess` (not a Worker Thread) to avoid SIGABRT on macOS; communicates via IPC
- **Printer integrations**: `renderer/printers//index.js` — the seven historical brands, wired by hand in `inventory.js`. **Printer plugins** (v2.41.0): a self-contained folder `renderer/printers//` with `plugin.js` (`registerPrinterPlugin`, host `renderer/printers/plugins.js`) and an optional `main.cjs` loaded by `main.js` (IPC `printer::`, preload bridge `window.printerPlugin.invoke`); a plugin imports host services only, never another brand's files, and `inventory.js` dispatches to it generically. Klipper and OctoPrint are plugins. Contract: `docs/PRINTER-PLUGINS.md`. "Find your printer" (`renderer/printers/model-finder.js`) searches every catalogue at once.
- **Demo mode** (`services/demo/`, open to every user of the desktop app — Add a printer → *Select a demo printer*, any of ~400 catalogue models; the machine's shape comes from its model, `shapes.js`): a printer simulator in the main process that answers each brand's real protocol on loopback addresses, so the unchanged drivers connect to fake printers — one shared print engine (`engine.js`), a print library with camera loops (`library.js`, `prints//`), one face per brand (`faces/.js`, all nine connectors), addresses / macOS loopback aliases (`addresses.js`). Records flagged `demo: true`; IPC `demo:*`, preload `window.demoMode`. Plan and per-brand contracts: `docs/DEMO-MODE.md`.
- **IoT sensors**: `renderer/IoT/td1s/` (TD1S color sensor), `renderer/IoT/tigerscale/` (TigerScale ESP32), `renderer/IoT/tigerspool/` (TigerSpool printer reader)
- **In-app requests**: `renderer/report/` — `index.js` (the *Improvements & suggestions* side card: topics, form, attachments, My requests with each request's conversation — read the replies, answer with text, pictures, PDFs and a printer's data — the "your request moved" alert window, and for TigerMasters (`roles: "tigermaster"`) and admins an "All requests" tab to answer as the team and close requests) + `annotator.js` (screenshot editor). Requests become public GitHub issues through the backend functions `reportIssue` / `myReports` (TigerTag_Firebase_Backend `functions/reportIssue.js`); the private trace lives in the server-only `bugReports/{id}` collection; status changes arrive as `users/{uid}/notifications` of type `request_status`.
- **CSS**: `renderer/css/00-base.css` → `72-hero-scene.css` (11 files, loaded in numeric order via `` tags)
- **i18n**: `renderer/locales/` — 11 JSON files (en/fr/de/es/it/zh/pt/pt-pt/pl/ru/nl), managed via `npm run i18n:add`
- **TigerTag SDK**: `@tigertag/sdk` npm package — parse, verify, encode TigerTag NTAG chips (pages 4–39, 144 bytes, blockSize=4)
## Key source files
- `renderer/CODEMAP.md` — maps every feature in `inventory.js` to a line range and anchor function name. **Read this before grepping inventory.js.**
- `renderer/inventory.js` — all application logic: state management, Firestore subscriptions, UI rendering, NFC handling, printer comms, friends system, telemetry
- `main.js` — IPC handlers: `app:info`, `rfid:encode-cloud`, `rfid:write-chip`, `rfid:read-chip`, `rfid:reader-update`, window management, auto-update
- `preload.js` — contextBridge: exposes `electronAPI` (IPC wrappers), `td1s` (TD1S sensor events)
- `services/nfc-process.js` — NFC utilityProcess: reader lifecycle, NTAG read (READ_START_PAGE=4, READ_LENGTH=144, READ_BLOCK_SIZE=4), card-present events; `read-now` also takes a `start` / `length` (capability container, a NTAG215/216's larger NDEF area)
- `services/ndef.js` — NDEF encoder (URI / Wi‑Fi / vCard records in a Type 2 TLV, sized to the chip) behind `rfid:write-ndef`, `rfid:ndef-size`, `rfid:ndef-capacity`
- `renderer/rfid_protocol/eraser/` — the NFC tools view (format, NDEF writing, Auto mode) of the new-chip card
- `renderer/rfid_protocol/restore/` — the Restore TigerTag+ view (rewrites a chip from its `rfidList` backup)
- `renderer/labels/` — printed spool and filament labels: eight templates, each one SVG in printer dots (`buildLabel`), Print / PDF / PNG / SVG through `main.js` `label:render`
- `renderer/qr-link.js` — the QR links of the TigerSystem-Docs contract (`spool-ids-and-qr-links.md`): build them and read them back offline; spool links open a spool from Studio Web `?spool=`, `tigertag://spool/` or a pasted link
## State object (renderer)
```
state = {
inventory, // raw Firestore docs { [spoolId]: data }
rows, // normalizeRow() output array
selected, // open detail panel spoolId
nfcReaders, // Set — connected readers
nfcCardPresent, // Map — readers with a card on them
friends, friendRequests,
publicKey, // XXX-XXX discovery code
privateKey, // 40-char hex access token
isPublic, isAdmin, debugEnabled,
lang, i18n, viewMode, sortCol, sortDir,
products, // { [keyHash]: product identity } — buy links, price, attachments, favourite
lists, // { [listId]: user-made list of product identities }
containerOverrides, // { [containerId]: { containerWeight?, favorite? } } — per-account calibration + favourite MasterSpools
activeAccountId, displayName, db
}
```
## AI assistants (local MCP server)
Studio can serve the Model Context Protocol on 127.0.0.1 so Claude Desktop / Claude Code /
Cursor / VS Code READ the signed-in account (inventory, friends' shared stock, wishlists,
racks, printers, devices, history). Read-only, credentials masked, documents returned
decoded. Reference: docs/MCP.md. A HOSTED twin runs on the Tiger Hub
(`https://mcp.tigersystem.io/mcp`, OAuth, no Studio needed — no live printer state); My profile
lists its connected assistants via `mcp:hosted-grants` (main-process IPC → Hub `/api/account/mcp-grants`).
## Firestore data model
```
publicKeys/{key} → { uid, claimedAt }
userProfiles/{uid} → { publicKey, displayName, isPublic, color, photoURL, socials, friendsCount,
tigerSquad: { since, tier, certified, xp } } ← SERVER-WRITTEN ONLY: Studio calls
the `setProfile` Cloud Function (renderer setProfile()), never writes it; tigerSquad from publishSupporter
config/studio → { minSquadVersion } world-readable: the oldest Studio allowed in the TigerSquad
(older builds lock it and show an update window; the endpoints answer 426)
publicStats/community → world-readable community totals (xpTotal, accounts, members, spools {…},
printers {total, byBrand}) published every minute by the communityStats function (a full count every 10 min + live counters in between)
publicStats/community/history/{YYYY-MM-DD} → one point per day of those totals
(both read by the TigerSquad Rankings view, never written by Studio)
squadBoard/{world|nat_CC|countries} → the TigerSquad leagues: world and country top 100 rows + every country's totals,
signed-in read, server-written (backend squadBoard.js, built by communityStats every 10 / 30 min) — never a list of all members
squadRank/{uid} → a member's world / national rank, filed nightly with the last 8 days (the ▲ / ▼ arrows),
signed-in read, server-written
squadLeagueHistory/{day} → each league's XP and member count at the end of a Paris day (countries: CC → { xp, n }),
signed-in read, server-written — the XP history's "My country" / "Leagues" curves
squadDeals/{id} → TigerSquad partner deals (Deals view), member read, written by the TigerHub admin only;
squadDeals/{id}/secret/main { code, link } readable only once the member's XP reaches minXp
squadDeals/{id}/claims/{uid} { code, used } the member's own acceptance (squadDeal function)
partnerUrl (public) + partnerClicks (server-counted); a deal may cost TigerCoins (costCoins)
users/{uid}/envelopes|chests|coinLog|easterEggs → TigerCoins (server-only, tigerCoins function): gift envelopes, chests,
every coin movement, easter eggs found; the balance is users/{uid}.tigerCoins { earned, spent }
users/{uid}/trophies/{id} → trophies earned, kept for good (server-only, functions/trophies.js; catalogue
functions/trophyRules.js); trophyStats/{id} { owners } + trophyEvents/{id} event cards,
signed-in read — the Trophies album (renderer/quests/trophies.js, docs/TROPHIES.md)
referrals/{inviteeUid} → who invited whom { referrerUid, claimedAt, expiresAt, … }, server-written
(functions/referrals.js) — the Invite view (renderer/quests/referrals.js, docs/REFERRALS.md)
publicLists/{token} → world-readable snapshot of a list shared by web link (unguessable
token); written by the owner when a list's visibility is "public"
users/{uid} → { displayName, email, publicKey, privateKey, isPublic,
roles, tier, Debug, ← roles + tier are SERVER-ONLY (rules reject client writes)
supportPublicly, ← the user CHOSE to join the TigerSquad (client-written)
supporter, xpPoints, certifiedPlusCount, tigerTagCount, xpCountVersion,
xpBreakdown, xpUnlocked, ← SERVER-ONLY (payment webhooks, certifyChip / countInventoryChip, XP achievements)
squadCountry, ← the member's TigerSquad flag (ISO alpha-2, client-written)
studioTheme, ← UI theme "dark"|"light" (Studio-only; `prefs/app` is the CROSS-APP doc)
studioVersion, studioElectron, studioPlatform, studioArch,
studioOsRelease, studioOsVersion, studioLang, studioLocale,
studioLastSeen }
/xpHistory/{id} → { at, key, delta, xp, n?, total } — one entry per XP gain/loss (SERVER-written, owner-read)
/td1s/{usbSerial} → { serialNumber, manufacturer, vendorId, productId, firstSeenAt, lastSeenAt } — each TD1S the account connected
/inventory/{spoolId} → { uid, id_brand, id_material, color_name, online_color_list,
weight_available, container_weight, container_id, capacity,
updatedAt, deleted, twin_tag_uid,
tag_index, tag_count } ← chip i of n (byte +39, protocol v2.2; 0 = unknown)
/telemetry/studio → { sessionsCount, versionsUsed[], platformsUsed[], lastSeen,
td1sUsed, rfidReadersMax }
/friends/{friendUid} → { displayName, addedAt, key }
/friendRequests/{uid} → { displayName, requestedAt, key }
/blacklist/{uid} → { displayName, blockedAt }
/products/{keyHash} → product identity shared by every spool of it: label, custom image,
buyUrl + price (HT/TTC), min stock, note, tags, favourite,
attachments[] (web links, tier-capped 1/2/5)
/lists/{listId} → user-made lists of product identities (itemKeys[]), shareable
/racks/{rackId} → rack layout docs
/stats/current → server-written rollup (stock value, counts) — client read-only
/dataHistory/{autoId} → server-written time series (≤ 1 point / 15 min) — client read-only
/printers/{brand}/devices/{id}
→ one doc per known printer of that brand (connection + identity).
THREE independent plans place things by hand, each with its own
field: the printer board (`plan`), the storage board and the
CAMERA VIEW (`camWallPlan: {x,y,z,w}` — the only one carrying a
width, since a camera is resized freely). A camera's place on the
printer board (`camPlan`) is a different object from its place on
the camera view; the two are never the same record.
/printers/{brand}/devices/{id}.units.{unitId}
→ the machine's FILAMENT STORAGE, a MAP field: one entry per unit
(an AMS / CFS / ACE box, a holder pair, the external arm).
Carries its shape (rows x cols), its label, `present`, a
position on EACH board (planPrinters / planRacks) and its
slots — each slot holding what the user assigned (`uids`)
and what the machine last reported in it (`seenAt` + colour,
material, vendor). Vocabulary + full shape:
docs/printer-storage-terms.md
/printers/{brand}/secrets/{doc}
→ SECRETS ONLY, a deliberate sibling of `devices/`.
`cloud_session` holds the Bambu account token, uid and
region — ONE per account, shared by every cloud printer;
`{dev_id}` holds that machine's LAN access code.
They are kept OUT of `devices/` because Firestore serves
a document whole: the day printers become friend-visible,
a friend-read must be scoped to `devices/**` alone, and a
secret stored on a printer's record would travel with it.
/printerSeen/{brand:id} → { ts } last-seen stamp per printer, drives the online/offline column
/prefs/app → { lang }
/scales/{mac} → TigerScale heartbeat
/tigerspools/{mac} → TigerSpool heartbeat (status, Wi-Fi, firmware, printers, printers_online)
/tigertouchs/{mac} → Tiger Touch heartbeat (status, Wi-Fi, firmware, printers, printers_online, demo, LAN mode — lan_access_code / lan_url are secrets)
/apiKeys/{docId} → API key docs
/telemetry/{docId} → aggregated usage metrics
/uidMigrationMap/{id} → decimal → hex UID migration
/containerOverrides/{id} → per-account container calibration { containerWeight?, favorite? } — overrides the
bundled catalogue weight at resolution time, never mutates it;
`favorite: true` puts a MasterSpool first when a refill asks for its container
/rfidAliases/{UID_HEX} → linked third-party chips (Bambu Lab, Creality… MIFARE Classic) → { spoolId, chip, vendor, linkedAt }
/devices/{id} → non-printer devices: type "camera" = external IP camera (owner-only, login in clear for the owner)
/rfidList/{chipUid} → physical chips seen by this account (firstSeen; a `backup` hex dump
means TigerTag+)
/rfidAliases/{UID_HEX} → a third-party chip (Bambu Lab, Creality… MIFARE Classic) linked to one spool
{ spoolId, chip, vendor, linkedAt } — docs/LINKED-CHIPS.md
/coinLog/{entryId} → every TigerCoins movement (opened, spent, given, received), read by the
TigerCoins history; server-written
/notifications/{id} → friend-to-friend notifications (field-whitelisted in the rules)
/apps/studio → per-app state flags (e.g. the onboarding version already run)
```
Chipless spools have `spoolId` starting with `TigerData_` (minted for new entries) or the legacy `CLOUD_` (older docs, not migrated — both recognised by `_isChiplessId`). `normalizeRow()` sets `r.isCloud = true` for either. On encoding, the Cloud doc is promoted: `spoolId` replaced by the chip UID, Cloud doc hard-deleted.
## Spool tiers (user-facing vocabulary)
Four tiers, visible as badges throughout the UI:
| Tier | What it is | Data |
|---|---|---|
| **TigerData** | fully digital, no physical chip | `spoolId` = `TigerData_` (or legacy `CLOUD_`); `r.isCloud === true` |
| **TigerData+** | fully digital, but tied to a real catalogue product | `r.isCloud && id_product ∉ {0, 0xFFFFFFFF}` → `r.isCloudPlus === true` |
| **TigerTag** | a written NFC chip | `spoolId` = chip UID |
| **TigerTag+** | a certified chip with a signed factory dump | UID + a `backup` hex dump in `rfidList` |
**TigerData+ is DERIVED from `isCloud + _hasRealProductId(id_product)` (`normalizeRow.isCloudPlus`), then MIRRORED**:
since v2.16.0 `normalizeRow` emits `protocol: "TigerData+"` and `syncSpoolMirrors` writes it onto the doc, so the tier
is readable straight from Firestore by the Hub / mobile / third parties. There is still no `tier` field and no doc-id
prefix of its own — the derived pair remains the definition, `protocol` is only its mirror, and docs written before
v2.16.0 still say `"TigerData"` until their owner reopens Studio (both values are in the wild meanwhile). Backend:
`byProtocol` gained a 4th `"TigerData+"` key and its `"TigerCloud"` key now means PLAIN TigerData — a deliberate series
break; `cloudCount` stays the whole chipless total and `cloudPlusCount` is the new subset. It is emphatically **not** a
TigerTag+ — no chip, no UID, and since v2.16.0 **no `id_tigertag` either**: that field names the chip version in
`id_version.json` (only 0 / 1542820452 / 1816240865 / 3155151767 are legal) so a chipless doc has nothing to put
there. It used to hold a random u32 "nonce" (Add Product v1.4.12, `.ttag` import v2.14.0, catalogue path v2.15.0) —
an out-of-referential value on every chipless doc. `syncSpoolMirrors` now DELETES it on chipless docs, so accounts
clean themselves. `_ttagRecordValid` requires `id_tigertag` only of chip-backed records (`_ttagNeedsTigertag`),
otherwise every TigerData would have become unexportable —
so a TigerData+ keeps the chipless `id_tigertag` nonce (`_catChiplessNonce` re-rolls off the version table, which
guarantees the row can never read as Plus) and gets its own badge (`.tag-cloud-plus`: the white chipless capsule
with an amber accent, never a gold fill). New spools of this tier are counted by `cloudPlusAddedTotal`, separate
from `cloudAddedTotal`.
**TigerData was called "TigerCloud" until v2.13.0.** As of v2.14.0 the rename reaches stored values too:
`normalizeRow` computes `protocol: "TigerData"` for chipless (so `syncSpoolMirrors` writes it to every doc), and
chipless doc ids migrate `CLOUD_` → `TigerData_` silently on login (`maybeMigrateCloudToTigerData`; both prefixes
still recognised forever by `_isChiplessId`, no server sweep). **One value stays `TigerCloud`: the `byProtocol`
telemetry rollup key** (`functions/index.js` buckets chipless under `"TigerCloud"`, derived from the id prefix, not
the field) — kept stable so historical analytics don't fragment. So: doc field + display + filter = `TigerData`;
only the analytics bucket key is still `TigerCloud`.
## Product catalogue — local cache + Catalogue view (v2.15.0, renamed v2.27.0)
The official TigerTag+ product catalogue (18 800+ products, growing daily) is **imported once, in full**, and every
search / filter / sort then runs **in memory** — the network is touched only to rebuild the cache, plus one
`product/get` per product actually opened (session-cached). Main process: IPC `catalog:fetch-all` (`main.js`) reads
the RFID-Guide snapshot `database/id_catalog.json` (raw.githubusercontent.com, auto-synced from the API several times
a day; ~1 s, kept on disk with its ETag so an unchanged catalogue is a `304`) since v2.46.2; only when it fails does
it page `POST product/get/all` with `per_page` 1000, following `env.nextPage` until null (60 s and two retries per
page) — the page cap is a runaway guard, NOT a catalogue-size limit, so never treat it as one. Renderer: cached in `localStorage` under `tigertag.catalog`,
self-refreshing past 24 h, force-rebuilt by *Re-sync the catalogue* in Settings → Tools. The cache carries a
schema version (`v: 2` since v2.17.0) and an older payload is rejected outright rather than migrated — v1 predates
the endpoint returning `series` / `name` as their own fields, and rejecting it is what allowed the title-splitting
fallback to be deleted: a rejected cache falls through to the bundled catalogue and a real sync follows.
The **Catalogue** view segment (`_isCatalogMode`, `renderCatalogView`, `#invCatalogView`) renders that cache through the
app's own shells — `.inv-grid`/`.spool-card` for the grid, a real `
` in `.table-wrap` for the list — with
Type · Brand · Series · Material · Sort filters at parity with the public catalogue page. It was LABELLED
"Search" until v2.27.0 — every identifier around it already said catalogue, so the label was the odd one out; the
i18n key moved with it (`viewGroupSearch` → `viewGroupCatalog`). A query that looks like a code (6+ chars with a
digit) is matched EXACTLY first against `sku` / `barcode` / `id`, so a barcode gun lands on one product rather than
on every product sharing that prefix; the inventory search applies the same rule against `sku` / `barcode` / `uid`.
**One builder feeds both the preview and the write**: `_catalogDocFromApi(api, productId, cloudId)` (over the shared
`_productApiFields`, extracted out of `_convertToPlus`). Selecting a product renders its Product card from that doc as
a `cloudSeed`, so the card previews exactly what an add would store; the square `+` calls `_catalogCreate(productId)`,
which writes the SAME doc. A click never creates. `_catIsCreatable` allows **Filament (142) only** — `data1`–`data7`
are per product type and only Filament is ratified in `docs/TTAG-FIELDS.md`; every other type is browsable and
refused with a plain-language message rather than written half-wrong.
## Inventory interchange — `.ttag` files (v2.14.0)
A `.ttag` file is a portable, offline snapshot of one or more inventory materials — the unit for backup, USB
transport and sharing. Shape: `{ format:"tigertag", kind:"ttag", version:1, exportedAt, exportedBy, records:[…
verbatim inventory docs …], rfidBackups? }`. Firestore Timestamps are normalised to epoch-ms; twins are atomic
(one material → both twin records); a legacy `CLOUD_` id is refused on export (only `TigerData_` chipless may
leave). `exportedBy` (owner uid) is a UX hint for import mode, never a security boundary. Import parses →
validates the marker (`version ≤ 1`) → **sanitises** every record (untrusted input: non-http(s) URL fields dropped,
colour/weight/TD clamped) → previews → writes in two modes: **Restore** (verbatim, keeps ids / TigerTag+ / the
`rfidList` backup) or **Import** (fresh chipless `TigerData_` spools you own — new `id_tigertag` nonce,
`id_product` unset, backups dropped, weight reset to capacity, `twin_tag_uid` remapped). Code:
`_buildTtag` / `_exportMaterialTtag` (export), `openTtagImportPicker` / `_ttagValidate` / `_ttagSanitizeRecord` /
`_ttagApplyImportAll` (import), all in `renderer/inventory.js`.
**Record contract, enforced at import (v2.15.0).** A `.ttag` exists to CREATE chips, so a record that cannot produce
one is refused rather than imported half-filled: `uid` plus the 24 numeric chip-payload fields of `TTAG_REQUIRED_NUM`
(the 10 identity ids incl. `id_product`, `measure`, the 4 colour bytes, `data1`–`data7`, `timestamp`) must all be
present and finite — `null`/`""` do NOT count (`Number(null)` is `0`, i.e. finite, which is exactly the trap
`_ttagNumOk` exists to avoid). A **material is atomic**: a twin pair passes only if BOTH records do. Refusals never
reach the preview and are counted back to the user (`ttagRejected`), never silently dropped; the write builder
re-filters as a last line of defence. Code: `TTAG_REQUIRED_NUM` / `_ttagRecordValid` / `_ttagMaterialValid`. The
field-level contract itself is authored in `playground/ttag-fields-editor` and generated into `docs/TTAG-FIELDS.md` —
never hand-edit that file, and never change the importer's contract without a founder-ratified regeneration.
## NFC / RFID system
- **ACR122U** via `nfc-pcsc` in a `utilityProcess` — avoids SIGABRT on macOS
- **NTAG read**: `reader.read(4, 144, 4)` — start page 4, 144 bytes (36 pages), blockSize=4 (critical: prevents sliding-window overlap)
- **Chip encode**: `TigerTag.fromCloudDoc(doc).patch({ timestamp }).toBytes(false)` → 80 bytes → write pages 4–0x17
- **Multi-reader**: up to 2 simultaneous readers; `state.nfcCardPresent` Map tracks which reader has a card
- **TigerPOD**: open-source dual reader/writer stand — 3D-printable shell + two USB readers (repo https://github.com/TigerTag-Project/TigerPOD, free STL MakerWorld #1289152)
RFID badge in header: always visible. Disconnected = filled red gradient pill, clicking opens TigerPOD modal. Connected = green tint. Card-present = filled green gradient.
TigerPOD modal opens from four triggers: (1) cloud spool banner click with no reader, (2) "Please update RFID" banner click with no reader, (3) red RFID badge click, (4) `+ Scan` button click with no reader.
## Printer integrations
Each brand has `renderer/printers//PROTOCOL.md` (self-contained spec) and `index.js` (implementation).
| Brand | Protocol | Port |
|---|---|---|
| Bambu Lab | MQTTS TLS (LAN) + account cloud MQTT / REST | 8883 |
| Creality | WebSocket | 9999 |
| Elegoo | MQTT (Centauri Carbon 2) + SDCP WebSocket (Centauri Carbon 1, beta) | 1883 / 3030 |
| FlashForge | HTTP poll + TCP M-codes | 8898 / 8899 |
| Snapmaker | Moonraker WebSocket | 7125 |
| Anycubic | MQTTS TLS 1.2 (LAN) + signed REST / cloud MQTT | 9883 |
| Prusa | PrusaLink HTTP poll (Digest auth) + Buddy3D RTSP camera — beta | 80 |
Plugins (`renderer/printers//plugin.js`, own `PROTOCOL.md` + `main.cjs`; not counted as brands): **Klipper** — Moonraker HTTP poll, any Klipper printer, Happy Hare / AFC slots (beta, 7125); **OctoPrint** — OctoPrint REST, Marlin printers behind OctoPi, API key via Application Keys, no filament slots (beta, 80). Klipper also shows every extra temperature the printer reports (`temperature_sensor`, `temperature_fan`, `heater_generic`) in a collapsible Sensors card, and the printer's Mainsail theme logo when it has one. Both plugins' add flow lists their models with a search (host service `printers/model-list.js`); their temperatures take a new target on click (host service `printers/temp-edit.js`). Mocks for tests from any device: `npm run moonraker:mock`, `npm run octoprint:mock` (`docs/PRINTER-MOCKS.md`). Bambu Lab cloud printers switch Cloud ⇄ LAN from their settings and fail over automatically (`bambulab/mode-switch.js`, `bambulab/failover.js`).
All brands: printer discovery (mDNS + port scan + manual IP), camera; filament edit per slot except Prusa (PrusaLink reports no loaded filament — slots show what the running job expects).
## Sensors
- **TD1S** (`renderer/IoT/td1s/`) — USB color + TD sensor. `window.td1s.onStatus(cb)` supports multiple listeners (IPC `on`, not `once`). Scan color (1–3 slots) and TD value independently or together.
- **TigerSpool** (`renderer/IoT/tigerspool/`) — ESP32-S3 printer-side RFID reader that writes a spool's filament into a printer slot. Studio reads `users/{uid}/tigerspools/{mac}`: header glyphs, side card per box (its printers, matched by doc id), launch card when none.
- **Tiger Touch** (`renderer/IoT/tigertouch/`) — Panda Touch / K-Touch screen firmware that follows the account's printers. Studio reads `users/{uid}/tigertouchs/{mac}`: header glyph, side card per screen (printers with the screen's own online dot, in-app live view (rle565 frames + long-poll + dirty rectangles, taps/swipes, fetched by the renderer — `screen.js`; BMP via main for older firmware), PNG screenshot via the main process → save / copy / pre-filled issue on the Tiger Touch repo), launch card when none.
- **TigerScale** (`renderer/IoT/tigerscale/`) — ESP32 scale over WebSocket. Live weight, container/filament split, TARE (hold-to-confirm), twin UID grid.
- **USB scale (Dymo M-series)** (`renderer/IoT/usbscale/`) — HID scale opened in the main process (`node-hid`, vid `0x0922`, usage page `0x8D`), streamed over IPC (`usb-scale-update` / `usb-scale-data`). One-write-per-pose state machine: POD mode saves silently when reader chip UIDs identify the spool; side-card mode offers an inline confirm. Live UI in the open spool's WEIGHT section (`#usbScaleDock`), with a "Weigh again" key. 5 s without a frame closes the handle and resumes polling (the M-series can sleep without leaving the bus). The scale's model / product id / firmware and first / last use are written to `telemetry/studio` (`usbScale*`, `firstUsbScaleAt` → 100 XP discovery).
## i18n system
`t(key, params?)` — looks up `state.i18n[state.lang][key]`, fallback chain: requested lang → en → key itself. Supports `{{param}}` interpolation, `["array"]` random pick, `{"one":"…","other":"…"}` plurals.
**Never edit locale JSON files by hand.** Use:
```bash
npm run i18n:add -- myKey --after existingKey en="..." fr="..." de="..." es="..." it="..." zh="..." pt="..." pt-pt="..." pl="..." ru="..." nl="..."
npm run i18n:check # validates consistency (also runs as pre-commit hook)
```
2940 keys × 11 locales as of v2.53.0
## CSS conventions
- **Every colour goes through a design token.** `:root` in `00-base.css` declares ~75 semantic tokens
and `:root[data-theme="dark"]` overrides them; a raw hex or rgba() anywhere else cannot follow the
theme and `npm run theme:check` fails on it. Four groups: surfaces/text/borders (invert), brand +
semantic accents (hue constant, `-soft` tints via `color-mix()` against `--surface`), fixed-polarity
(`--on-accent` = the label ON a filled button, white in BOTH themes), and always-dark (tooltips,
media, the Add-Product panel). The **ink ladder** `--ink-04`…`--ink-85` is contrast-against-the-current-
ground and flips polarity with the theme — reach for it instead of hand-writing a translucent black or white.
- **The elevation ladder inverts between themes.** In light, `--surface` is the LIGHTEST step; in dark it
is darker than `--surface-2`. So a highlight sitting on a recessed track must use `--surface-raised`
("above the surface"), never `--surface`, or it vanishes in one of the two themes.
- One dimension + `aspect-ratio` for SVG mask icons (never both `width` and `height`)
- Asset URLs: `url('../../assets/svg/icons/…')` from `renderer/css/` (two `..` to project root)
- Specificity: double class selector instead of `!important` when fighting global rules
- Hold-to-confirm buttons: `setupHoldToConfirm(el, durationMs, cb)` with `` child
## Key JS patterns
- **Modals**: `.modal-overlay` + `.modal-card`, toggled via `.open` class
- **Resizable panels**: `makePanelResizable(panelEl, handleEl, storageKey)` — drag handle, localStorage persistence
- **Weight slider auto-save**: 500 ms debounce → Firestore write; `.wb-saving` pulse class during window
- **Health indicator**: `#health` cloud icon; `fromCache === false` → green, `true` → red; lazy RTT ping on `mouseenter`
- **Telemetry**: two-level — `users/{uid}` (last-known, overwrite) + `users/{uid}/telemetry/studio` (lifetime, FieldValue.increment/arrayUnion). Guards `_telTd1s` / `_telRfidMax` prevent duplicate writes per session.
## Auth & security
- Firebase Auth (Google OAuth loopback PKCE + email/password)
- `displayName` always from Firestore `users/{uid}.displayName` (pseudo), never from Firebase Auth `user.displayName` (Google real name)
- `roles` and `tier` fields writable only via Firebase Admin SDK / Cloud Function — never by client; `Debug` is a user preference the client writes on its own doc (any account can enable debug mode)
(`tier` gates the attachment cap: free 1 / plus 2 / premium 5, enforced server-side)
- Firestore Security Rules enforce owner-only access; friends granted read on `inventory`, `racks`, `products` and
`lists` via presence in the owner's `friends/` subcollection. **The rules live in a separate repo**
(`TigerTag_Firebase_Backend/firestore.rules`), not here
- Several collections are **field-whitelisted** (`hasOnly([...])`) — an unlisted field is rejected *silently* on the
client, so adding a field means updating the rules and redeploying
## LocalStorage keys
`tigertag.accounts` · `tigertag.activeAccount` · `tigertag.inv.` · `tigertag.view` · `tigertag.lang` · `tigertag.sidebar` · `tigertag.panelWidth.detail` · `tigertag.panelWidth.debug` · `tigertag.theme`
## Build & release
```bash
npm start # dev (no bundler)
npm run i18n:check # validate all 11 locales
git tag v2.13.0 && git push --tags # triggers CI: parallel build macOS/Windows/Linux → GitHub Release
```
CI: `.github/workflows/build.yml` — Apple Developer ID signing + notarytool on macOS, electron-builder on all platforms, publishes to GitHub Releases.
## Ecosystem repositories
Tiger Studio Manager is the desktop app of a wider open hardware + software ecosystem.
| Project | What it is | Repository |
|---|---|---|
| TigerTag SDK for JavaScript | Parse, verify and encode TigerTag NFC chips — used internally by this app | https://github.com/TigerTag-Project/TigerTag-SDK-JS |
| TigerTag SDK for Python | Same operations in Python, for scripts and automation | https://github.com/TigerTag-Project/TigerTag-SDK-Python |
| TigerScale V3 | ESP32-S3 firmware + hardware for the open-source filament scale (touchscreen, dual NFC, battery) | https://github.com/TigerTag-Project/Tiger-Scale-V3 |
| TigerPOD | Open-source dual NFC reader/writer stand for spools — 3D-printable shell + two USB readers | https://github.com/TigerTag-Project/TigerPOD |
| Tiger Touch (github.com/TigerTag-Project/Tiger-Touch, v2.0.0) | Open firmware (MIT, ESP-IDF + LVGL) for the BIGTREETECH Panda Touch / K-Touch 7" screen: signs in to the TigerTag account, reads the printers declared in Tiger Studio (`users/{uid}/printers/{brand}/devices`) and monitors each one over its own protocol — live cards, filament units, pause / resume / stop, light; installed from the browser, updated over the air | not public yet |
## Supporting the project
TigerSystem is a personal, community open-source project, not a company product. Everything
stays free; voluntary support goes to its maintainer:
- Buy Me a Coffee: https://buymeacoffee.com/benoitl
- Ko-fi: https://ko-fi.com/tigersystemio
- PayPal: https://paypal.me/tigersystemio
**TigerSquad** (v2.37.0) — the opt-in public community / wall of fame. Levels are computed by
the backend (`functions/supportWebhooks.js`): bronze = joined, silver = 20+ TigerTag chips
(`tigerTagCount`), gold = donated (`supporter`), platinum = donated + 50 certified TigerTag+
(`certifiedPlusCount`, verified with the TigerTag SDK). XP = chip points (certified 10, TigerTag+ 2,
TigerTag 1) counted once per chip UID (`countedChips/{uid}`, server-only) from the census AND the
inventory, + 50 while a member. A donation alone gives no badge — the badge comes with joining.
Since v2.38.0 XP also comes from achievements computed server-side (`achievementParts`): STATE parts
(friends, social links, photo, public profile, printers, TigerScale, TigerSpool) are lost when removed;
DISCOVERIES (phone app, TigerPOD, RFID reader, TD1S, first rack, first list, each sidebar community
link) are kept in `users/{uid}.xpUnlocked`. The split is `users/{uid}.xpBreakdown`, every change is
logged in `users/{uid}/xpHistory` (server-only), and the server writes `xp_gain` (one per day) and
`squad_level` notifications. Public ranking data = `userProfiles/{uid}.tigerSquad` { since, tier,
xp, certified, country } — the Countries League sums `xp` per `country` (`users/{uid}.squadCountry`).
**Tiger Quests** (v2.43.0) — spec `docs/QUESTS.md`. The app reports what the user does as batched
counters in `users/{uid}/telemetry/studio.quest` (`questBump(key, n, printer)` in
`renderer/quests/index.js`; a Demo printer never counts) and checks in with the backend's `dailyQuest`.
The server (`functions/quests.js`) owns everything that pays: today's draw and full set in
`quests/daily`, the tutorial-missions journey (Discovery 1–5, Explorer 1–5, five actions per level)
in `quests/journey`, folded into the XP as the `daily` / `updates` / `journey` breakdown parts.
Views of the main area: Tiger Quests (Today, Missions), TigerSquad (Rankings, Items) and the XP
history page (`renderer/quests/xp-history.js`, from `users/{uid}/xpHistory`). The leagues read
`squadBoard/*` and `squadRank/{uid}` (backend `squadBoard.js`): a few documents per open, whatever
the member count.
## Optional docs
- `renderer/CODEMAP.md` — line ranges for every feature in `inventory.js`
- `ROADMAP.md` — what's done / in progress / backlog, grouped by domain
- `DEVLOG.md` — per-session engineering journal
- `renderer/printers//PROTOCOL.md` — self-contained agent skill per printer brand
- `docs/printer-board-widgets.md` — how to add a widget to the printer board, what it
costs today (thirteen edits in eleven places), the traps that cost an evening, and the
registry that would reduce it to one entry. READ IT before adding a board widget
- `docs/printer-storage-terms.md` — the agreed words for a printer's filament storage
(`slot` = one place for one spool, `unit` = the physical box grouping slots: AMS / AMS HT /
CFS / ACE / holder / ext). READ IT before naming anything in this area — every brand names
the same object differently, and one unit maps to one bound rack
- `TIGERTAG_FIREBASE_INTEGRATION.md` — third-party Firebase integration guide
- `FEATURES.md` — consolidated catalogue of every shipped feature, tagged by version
- `CODEMAP-main.md` — line ranges for every section of `main.js`