Something went wrong. Try again.
A decentralized music tracking and discovery platform built on AT Protocol ๐ต rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636// NFC tags as physical shortcuts to a library album, playlist or favorites.//// Wire format is identical to the desktop app's (desktop/src-tauri/src/nfc.rs),// which has a test pinning both encoders to the same bytes: NDEF URI records// holding the album's or playlist's `at://` record URI first, then a// `rocksky://library/<kind>/<id>` link to this server's own id as a fallback.// A tag written in either app therefore plays in both.//// The PC/SC transport lives in `nfc-pcsc`, a native module listed as an// OPTIONAL dependency: it needs pcsclite headers to build, and the CLI must// install and run fine on machines that will never see a reader. Every entry// point here therefore loads it lazily and reports a plain message when it is// absent, rather than exploding at import time.
// Type-only, so it erases at compile time and the runtime import below stays// the single lazy entry point into the optional native module.import type { Reader } from "nfc-pcsc";
/** Type 2 tag user memory starts at page 4; pages 0โ3 are UID/lock/CC. */const FIRST_DATA_PAGE = 4;const PAGE_LEN = 4;/** NTAG213 user memory. Enough for every URI we write, and the smallest tag * people actually buy โ so it is what we read and size against. */const DEFAULT_CAPACITY = 144;
/** NDEF URI abbreviations (NFC Forum URI RTD, table 6), in code order. */const URI_PREFIXES = [ "", "http://www.", "https://www.", "http://", "https://", "tel:", "mailto:", "ftp://anonymous:anonymous@", "ftp://ftp.", "ftps://", "sftp://", "smb://", "nfs://", "ftp://", "dav://", "news:", "telnet://", "imap:", "rtsp://", "urn:", "pop:", "sip:", "sips:", "tftp:", "btspp://", "btl2cap://", "btgoep://", "tcpobex://", "irdaobex://", "file://", "urn:epc:id:", "urn:epc:tag:", "urn:epc:pat:", "urn:epc:raw:", "urn:epc:", "urn:nfc:",];
// โโ Payload scheme โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export type NfcTarget = | { kind: "albumUri"; uri: string } | { kind: "playlistUri"; uri: string } | { kind: "album"; id: string } | { kind: "playlist"; id: string } | { kind: "favorites"; did: string };
/** * What gets burned onto the tag, one NDEF record per entry, in order. * * The record's AT-URI first whenever there is one: it names the album or * playlist itself, so the tag is portable โ it resolves on any machine, for any * user, against any server. The `rocksky://library/...` link to this server's * own id follows as a fallback, for when the record URI resolves to nothing * (the record was never published, or was deleted while the upload stayed). * * An entity with no record yet gets the id link alone, and such a tag resolves * against this user's library only. */export function nfcPayloadsFor( kind: "album" | "playlist", ref: { uri?: string | null; id?: string | null },): string[] { const uri = ref.uri?.trim(); const id = ref.id?.trim(); return [uri, id && `rocksky://library/${kind}/${id}`].filter( (p): p is string => !!p, );}
/** * What gets burned onto a favorites tag. * * Favorites are a query, not a record: there is no AT-URI to write and no * library id either, so the tag names the person instead โ their DID plus the * label for what to play. Any Rocksky player signed in as them resolves it. */export function nfcFavoritesPayloads(did: string): string[] { const value = did.trim(); return value ? [`rocksky://favorites/${encodeURIComponent(value)}`] : [];}
const AT_URI = /^at:\/\/[^/]+\/app\.rocksky\.(album|playlist)\/[^/]+$/i;
/** Read a tag payload back into something playable, or null if it isn't ours. */export function parseNfcPayload(payload: string): NfcTarget | null { const value = payload.trim();
// A record URI names the album or playlist itself. The Navidrome API resolves // one directly (getAlbum / getPlaylist / search3 all take a URI), so a tag // written by any Rocksky client โ or by a phone's tag writer โ works here. const record = AT_URI.exec(value); if (record) { return record[1].toLowerCase() === "album" ? { kind: "albumUri", uri: value } : { kind: "playlistUri", uri: value }; }
// Legacy / unmirrored form: a library id, meaningful only on the server that // issued it. Still read so tags written before the switch keep working. const library = /^rocksky:\/\/library\/(album|playlist)\/(.+)$/i.exec(value); if (library) { return { kind: library[1].toLowerCase() as "album" | "playlist", id: decodeURIComponent(library[2]), }; }
// Favorites name their owner rather than a record โ see nfcFavoritesPayloads. const favorites = /^rocksky:\/\/favorites\/(.+)$/i.exec(value); if (favorites) { return { kind: "favorites", did: decodeURIComponent(favorites[1]) }; }
return null;}
/** * Every target a tag's records name, best first. Anything unrecognised is * dropped rather than ending the list โ a foreign record sitting alongside ours * shouldn't hide the one we can play. */export function parseNfcPayloads(payloads: string[]): NfcTarget[] { return payloads .map(parseNfcPayload) .filter((t): t is NfcTarget => t !== null);}
// โโ NDEF โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/** * NDEF URI records wrapped in a Type 2 NDEF TLV, padded to whole pages. * * Order carries meaning: a reader tries the records front to back, so the * portable record URI goes first and the library-id fallback second. A reader * that only looks at the first record โ an older build, a phone โ sees exactly * the single-record tag it would have seen before. * * Byte-for-byte identical to the desktop app's encoder * (desktop/src-tauri/src/nfc.rs), which has a test pinning both. */export function encodeNdefUris(uris: string[]): Buffer { const records = uris.map((uri, i) => { let code = 0; let rest = uri; URI_PREFIXES.forEach((prefix, p) => { // Longest match wins, so "https://www." beats "https://". if (p > 0 && uri.startsWith(prefix) && prefix.length > URI_PREFIXES[code].length) { code = p; rest = uri.slice(prefix.length); } });
const payload = Buffer.concat([Buffer.from([code]), Buffer.from(rest, "utf8")]); // SR|TNF=1 (well known), plus MB on the first record and ME on the last โ // both on a lone record, which is the 0xd1 tags carried before. const flags = 0x11 | (i === 0 ? 0x80 : 0) | (i === uris.length - 1 ? 0x40 : 0); return Buffer.concat([Buffer.from([flags, 0x01, payload.length, 0x55]), payload]); });
const body = Buffer.concat(records); const header = body.length < 0xff ? Buffer.from([0x03, body.length]) : Buffer.from([0x03, 0xff, body.length >> 8, body.length & 0xff]);
const tlv = Buffer.concat([header, body, Buffer.from([0xfe])]); const padding = (PAGE_LEN - (tlv.length % PAGE_LEN)) % PAGE_LEN; return Buffer.concat([tlv, Buffer.alloc(padding)]);}
/** * Trims `uris` to what a tag of `capacity` bytes can hold, keeping at least the * first. Trailing records are droppable: the first is the one that plays and * the rest only matter if it stops resolving, so a small tag is better off with * a working shortcut than with no tag at all. */export function fitNdefUris(uris: string[], capacity = DEFAULT_CAPACITY): string[] { let take = uris.length; while (take > 1 && encodeNdefUris(uris.slice(0, take)).length > capacity) take -= 1; return uris.slice(0, take);}
/** Walk the TLV chain and return the NDEF message's bytes. */function findNdefTlv(data: Buffer): Buffer | null { let i = 0; while (i < data.length) { const tag = data[i]; if (tag === 0x00) { i += 1; // NULL padding continue; } if (tag === 0xfe) return null; // terminator if (i + 1 >= data.length) return null;
const long = data[i + 1] === 0xff; const len = long ? (data[i + 2] << 8) | data[i + 3] : data[i + 1]; const start = i + (long ? 4 : 2); const end = start + len; if (end > data.length) return null; if (tag === 0x03) return data.subarray(start, end); i = end; } return null;}
/** * Decode every well-known URI record in an NDEF message, in order. Only short * records are handled โ a tag we wrote is always one, and a foreign tag whose * link needs a 32-bit length is not something this app can play. * * A malformed record ends the walk and keeps whatever came before it: the first * record is the one that plays, so a damaged fallback must not cost the caller * the good record ahead of it. */function uriRecords(message: Buffer): string[] { const uris: string[] = []; let i = 0; while (i + 3 <= message.length) { const flags = message[i]; const short = (flags & 0x10) !== 0; const hasId = (flags & 0x08) !== 0; const typeLen = message[i + 1];
let payloadLen: number; let cursor: number; if (short) { payloadLen = message[i + 2]; cursor = i + 3; } else { if (i + 6 > message.length) break; payloadLen = message.readUInt32BE(i + 2); cursor = i + 6; } if (hasId) { if (cursor >= message.length) break; cursor += 1 + message[cursor]; }
const recType = message.subarray(cursor, cursor + typeLen); if (recType.length < typeLen) break; cursor += typeLen; const payload = message.subarray(cursor, cursor + payloadLen); if (payload.length < payloadLen) break;
// TNF 1 (well known) + type "U". if ((flags & 0x07) === 0x01 && recType.toString("ascii") === "U" && payload.length > 0) { uris.push((URI_PREFIXES[payload[0]] ?? "") + payload.subarray(1).toString("utf8")); }
if ((flags & 0x40) !== 0) break; // ME: last record i = cursor + payloadLen; } return uris;}
export function decodeNdefUris(data: Buffer): string[] { const message = findNdefTlv(data); return message ? uriRecords(message) : [];}
// โโ MIFARE Classic โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ//// Classic isn't an NFC Forum tag type, but NXP AN1305 maps NDEF onto it and// that is what phones read and write โ so a Classic tag sold as an "NFC tag"// carries an ordinary NDEF message, addressed differently: 16-byte blocks in// 4-block sectors, each sector behind a key exchange, the last block of every// sector being its trailer. Mirrors desktop/src-tauri/src/nfc.rs.
const CLASSIC_BLOCK_LEN = 16;/** Three data blocks per sector; the fourth is the trailer. */const CLASSIC_SECTOR_BYTES = 3 * CLASSIC_BLOCK_LEN;const KEY_A = 0x60;const KEY_B = 0x61;
/** The NFC Forum's well-known key A for NDEF sectors (AN1305 ยง3.3). */const NDEF_KEY = Buffer.from([0xd3, 0xf7, 0xd3, 0xf7, 0xd3, 0xf7]);/** The factory transport key: a tag that answers to this is unformatted. */const FACTORY_KEY = Buffer.from([0xff, 0xff, 0xff, 0xff, 0xff, 0xff]);/** Key A of the MAD sector, fixed by the spec so any reader can read it. */const MAD_KEY = Buffer.from([0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5]);
export type TagKind = | { kind: "type2" } | { kind: "classic"; name: string; sectors: number } | { kind: "unknown" };
/** * What the card is, from the ATR the reader synthesises for storage cards * (PC/SC part 3 ยง3.1.3.2.3.1). The two-byte card name sits just past the * RID `A0 00 00 03 06` and a standard byte. */export function tagKindFromAtr(atr?: Buffer | null): TagKind { if (!atr) return { kind: "unknown" }; const rid = Buffer.from([0xa0, 0x00, 0x00, 0x03, 0x06]); const at = atr.indexOf(rid); if (at < 0 || at + rid.length + 3 > atr.length) return { kind: "unknown" }; const name = atr.readUInt16BE(at + rid.length + 1); switch (name) { case 0x0003: return { kind: "type2" }; case 0x0001: return { kind: "classic", name: "MIFARE Classic 1K", sectors: 16 }; // Only a 4K's first 32 sectors are the 4-block kind addressed here. case 0x0002: return { kind: "classic", name: "MIFARE Classic 4K", sectors: 32 }; case 0x0026: return { kind: "classic", name: "MIFARE Mini", sectors: 5 }; default: return { kind: "unknown" }; }}
/** Sector trailer: key A, three access bytes, the GPB, then key B. Key B stays * the factory value so a wrong trailer can always be rewritten. */function trailer(keyA: Buffer, access: number[]): Buffer { return Buffer.concat([keyA, Buffer.from(access), FACTORY_KEY]);}
/** CRC-8 over the MAD's bytes: polynomial 0x1D, preset 0xC7. */function crc8Mad(data: Buffer): number { let crc = 0xc7; for (const b of data) { crc ^= b; for (let i = 0; i < 8; i++) { crc = crc & 0x80 ? ((crc << 1) ^ 0x1d) & 0xff : (crc << 1) & 0xff; } } return crc;}
/** MAD1 blocks 1 and 2: the directory claiming every sector from 1 up for the * NDEF AID (0x03E1, little-endian). Byte 0 is the CRC over all that follows; * byte 1 is the card-publisher sector, 0 for none. */function madBlocks(sectors: number): [Buffer, Buffer] { const aids: number[] = []; for (let s = 1; s < Math.min(sectors, 16); s++) aids.push(0xe1, 0x03); const b1 = Buffer.concat([Buffer.from([0x00, 0x00]), Buffer.from(aids.slice(0, 14))]); const b2 = Buffer.concat([Buffer.from(aids.slice(14))]); b1[0] = crc8Mad(Buffer.concat([b1.subarray(1), b2])); return [b1, b2];}
/** The data blocks of sectors 1.., in order. Sector 0 holds the MAD and every * fourth block is a trailer, so neither is ever addressed. */function classicDataBlocks(sectors: number): number[] { const blocks: number[] = []; for (let s = 1; s < sectors; s++) for (let b = 0; b < 3; b++) blocks.push(s * 4 + b); return blocks;}
async function tryAuth(reader: Reader, key: Buffer, sector: number): Promise<boolean> { try { await reader.authenticate(sector * 4, KEY_A, key); return true; } catch { return false; }}
/** * Turn a factory-blank Classic tag into an NDEF one. Only ever called when the * factory key still opens everything, so there is nothing of anyone's to lose. * NDEF sectors first, MAD last: an interrupted format leaves a directory that * still says "not NDEF", which is where it started. */async function classicFormat(reader: Reader, sectors: number): Promise<void> { const blank = Buffer.alloc(CLASSIC_BLOCK_LEN); for (let s = 1; s < sectors; s++) { if (!(await tryAuth(reader, FACTORY_KEY, s))) { throw new Error(`sector ${s} wouldn't authenticate to format`); } for (let b = 0; b < 3; b++) { await reader.write(s * 4 + b, blank, CLASSIC_BLOCK_LEN); } await reader.write(s * 4 + 3, trailer(NDEF_KEY, [0x7f, 0x07, 0x88, 0x40]), CLASSIC_BLOCK_LEN); }
if (!(await tryAuth(reader, FACTORY_KEY, 0))) { throw new Error("the MAD sector wouldn't authenticate to format"); } const [b1, b2] = madBlocks(sectors); await reader.write(1, b1, CLASSIC_BLOCK_LEN); await reader.write(2, b2, CLASSIC_BLOCK_LEN); // GPB 0xC1: MAD present, version 1, multi-application. await reader.write(3, trailer(MAD_KEY, [0x78, 0x77, 0x88, 0xc1]), CLASSIC_BLOCK_LEN);}
/** Read the NDEF message off a Classic tag. Stops at the first sector that * won't authenticate โ that is the end of the NDEF area. */export async function classicReadNdef(reader: Reader, sectors: number): Promise<string[]> { const parts: Buffer[] = []; for (let s = 1; s < sectors; s++) { if (!(await tryAuth(reader, NDEF_KEY, s))) break; try { parts.push(await reader.read(s * 4, CLASSIC_SECTOR_BYTES, CLASSIC_BLOCK_LEN)); } catch { break; } } return decodeNdefUris(Buffer.concat(parts));}
/** Write an NDEF TLV across a Classic tag's sectors, formatting it first if it * has never held NDEF. Only data blocks are touched. */export async function classicWriteNdef( reader: Reader, bytes: Buffer, sectors: number,): Promise<void> { if (!(await tryAuth(reader, NDEF_KEY, 1))) { if (!(await tryAuth(reader, FACTORY_KEY, 1))) { throw new Error( "this MIFARE Classic tag is locked with keys we don't have, so it can't be written", ); } await classicFormat(reader, sectors); }
// A block write is all 16 bytes or nothing; encodeNdefUris pads to the Type 2 // page size, a quarter of that. const padLen = (CLASSIC_BLOCK_LEN - (bytes.length % CLASSIC_BLOCK_LEN)) % CLASSIC_BLOCK_LEN; const padded = Buffer.concat([bytes, Buffer.alloc(padLen)]);
const blocks = classicDataBlocks(sectors); const chunks: Buffer[] = []; for (let i = 0; i < padded.length; i += CLASSIC_BLOCK_LEN) { chunks.push(padded.subarray(i, i + CLASSIC_BLOCK_LEN)); } if (chunks.length > blocks.length) { throw new Error( `this tag holds ${blocks.length * CLASSIC_BLOCK_LEN} bytes of NDEF; ` + `the link needs ${padded.length}.`, ); }
// The block carrying the TLV header goes last, so an interrupted write leaves // a tag that reads as blank rather than as half a record. const plan = chunks.map((chunk, i) => ({ block: blocks[i], chunk })); if (!plan.length) throw new Error("nothing to write"); plan.push(plan.shift()!);
let authed = -1; for (const { block, chunk } of plan) { const sector = Math.floor(block / 4); if (authed !== sector) { if (!(await tryAuth(reader, NDEF_KEY, sector))) { throw new Error(`sector ${sector} wouldn't authenticate`); } authed = sector; } await reader.write(block, chunk, CLASSIC_BLOCK_LEN); }}
/** How many bytes of NDEF a tag of this kind can hold. */export function tagCapacity(kind: TagKind): number { return kind.kind === "classic" ? (kind.sectors - 1) * CLASSIC_SECTOR_BYTES : DEFAULT_CAPACITY;}
// โโ Reader โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export class NfcUnavailableError extends Error { constructor(cause: string) { super( `NFC is unavailable: ${cause}\n` + "Install the optional reader support with `npm i -g nfc-pcsc`, and make " + "sure a PC/SC reader (ACR122U or another ACS/CCID model) is plugged in.", ); this.name = "NfcUnavailableError"; }}
async function loadNfc(): Promise<typeof import("nfc-pcsc")> { try { return await import("nfc-pcsc"); } catch (e: any) { throw new NfcUnavailableError(e?.message ?? "nfc-pcsc is not installed"); }}
export interface TagEvent { uid: string; /** * The URIs stored on the tag, in tag order โ the first is what should play, * any after it are fallbacks. Empty when it holds no NDEF URI record. */ payloads: string[]; reader: string;}
export interface NfcSession { /** Fires for every tag tapped on any connected reader. */ onTag(handler: (tag: TagEvent) => void): void; /** Fires when a reader is plugged in or unplugged. */ onReader(handler: (name: string, connected: boolean) => void): void; /** * Arm a write: the next tag tapped gets one NDEF record per entry of * `payloads`. Resolves with its UID. Rejects on timeout so a forgotten prompt * doesn't overwrite a tag hours later. */ write(payloads: string[], timeoutMs?: number): Promise<string>; close(): void;}
/** * Open the reader. The session multiplexes taps between "someone is waiting to * write" and "play whatever this tag points at" โ one PC/SC connection, because * a second one would fight the first for exclusive access to the card. */export async function openNfc(): Promise<NfcSession> { const { NFC } = await loadNfc(); const nfc = new NFC();
const tagHandlers: ((tag: TagEvent) => void)[] = []; const readerHandlers: ((name: string, connected: boolean) => void)[] = []; let pending: { payloads: string[]; resolve: (uid: string) => void; reject: (e: Error) => void; } | null = null;
nfc.on("reader", (reader) => { readerHandlers.forEach((h) => h(reader.reader.name, true));
reader.on("card", async (card: { uid?: string; atr?: Buffer }) => { const uid = card.uid ?? ""; // Type 2 and Classic hold the same NDEF message over different protocols; // the ATR says which is on the reader. const kind = tagKindFromAtr(card.atr);
if (pending) { const job = pending; pending = null; try { const fitted = fitNdefUris(job.payloads, tagCapacity(kind)); const bytes = encodeNdefUris(fitted); if (kind.kind === "classic") { await classicWriteNdef(reader, bytes, kind.sectors); } else { await reader.write(FIRST_DATA_PAGE, bytes, PAGE_LEN); } job.resolve(uid); } catch (e: any) { job.reject(new Error(e?.message ?? "the tag rejected the write")); } return; }
let payloads: string[] = []; try { payloads = kind.kind === "classic" ? await classicReadNdef(reader, kind.sectors) : decodeNdefUris(await reader.read(FIRST_DATA_PAGE, DEFAULT_CAPACITY, PAGE_LEN)); } catch { // Unreadable tag (wrong type, moved off the reader mid-read). } tagHandlers.forEach((h) => h({ uid, payloads, reader: reader.reader.name })); });
reader.on("error", () => { // Transient reader errors are normal when a tag is pulled away mid-read. }); reader.on("end", () => readerHandlers.forEach((h) => h(reader.reader.name, false))); });
nfc.on("error", () => { // PC/SC service hiccup; the library reconnects on its own. });
return { onTag: (h) => tagHandlers.push(h), onReader: (h) => readerHandlers.push(h), write(payloads, timeoutMs = 30_000) { pending?.reject(new Error("superseded by another write")); return new Promise<string>((resolve, reject) => { const timer = setTimeout(() => { if (pending?.resolve === wrapped) pending = null; reject(new Error("timed out waiting for a tag")); }, timeoutMs); const wrapped = (uid: string) => { clearTimeout(timer); resolve(uid); }; pending = { payloads, resolve: wrapped, reject: (e) => { clearTimeout(timer); reject(e); }, }; }); }, close() { pending?.reject(new Error("the reader was closed")); pending = null; try { nfc.close(); } catch { // Already torn down. } }, };}