import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { OFFICER_ITEMS_DIR } from '../data-path'; // The notification catalogue: what a code MEANS, assembled here and rendered on the device. // // ── Why the meaning lives on the server ── // // A push carries a code and some integers — `officerdev/music:12.3`, `n: 3` — and never content. The // device turns that into the notification you actually see. If the code-to-text mapping lived in the app, // every new kind of notification would be an App Store release; because it lives here and is synced, a // new kind is a server change and nothing else. That single property is what the whole design is for. // // See docs/push-notifications.md for the design and docs/push-notifications-app.md for the client contract. // // ── The namespace is DERIVED, never declared ── // // A code is `/:`, and the publisher and appName come from WHERE THE FILE IS — // `notifications///*.yaml` — not from anything inside it. Same rule as `mountPrefix`. // A plugin therefore cannot claim `officer/core:` or another publisher's namespace by saying so; it would // have to be installed somewhere it cannot install itself. const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML; export const NOTIFICATIONS_DIR = join(OFFICER_ITEMS_DIR, 'notifications'); /** How one code renders, and where tapping it goes. */ export type NotificationEntry = { /** Template. `{n}` and `{id}` are substituted on the device. */ title: string; /** Template, optional — most notifications are a title alone. */ body?: string; /** Which app the deep link opens. */ target?: string; /** The deep link, `{id}` interpolated. */ route?: string; /** * Grouping key, so like-notifications collapse rather than stack. Defaults to the code's namespace. * * A TEMPLATE, like the others — `chat:{id}` groups per conversation, so two busy chats do not collapse * into one banner while three pings from the SAME chat still do. A static thread would force a choice * between those, and the wrong one is "every chat is the same chat". */ thread?: string; }; export type PluginCatalogueSource = { appName: string; dir: string; manifest: { publisher: string } }; export type Catalogue = { /** * Changes whenever the aggregate does. A CONTENT HASH, not a counter. * * A counter would need somewhere to live, and would then be wrong after a restore, a rollback, or two * servers built from the same install. The hash needs no storage and is right in all three cases — * which is why the client compares `mv !== version` rather than `mv > version`. */ version: number; entries: Record; }; /** * The platform's own, shipped in code rather than on disk. * * Plugin catalogues arrive through `capabilities/notifications/`, which is installed rather than * committed. The platform's must not depend on that having happened — `test` in particular has to exist * on a machine that has never installed anything, because it is what first light is proved with. */ const CORE_ENTRIES: Record = { // ── Chat ── // // The hub has no chat screen; chat is Butler, a sibling app. So these route OUT of the hub over // `officer-chat://`, and the scheme is written here rather than derived from the target: two of the // suite's twenty-one schemes already break the `officer-` pattern (`officer-videos`, // `officer-readaloud`), so deriving it works until it silently does not. // // The banner cannot name WHICH conversation — a chat's title is content, and content does not travel. // It says what happened; tapping opens the chat, which is where the name lives. 'officer/core:chat.turn-ended': { title: 'Chat', body: 'Finished its turn', target: 'chat', route: 'officer-chat://session/{id}', thread: 'chat:{id}', }, 'officer/core:chat.input-needed': { title: 'Chat', body: 'Needs your input', target: 'chat', route: 'officer-chat://session/{id}', thread: 'chat:{id}', }, 'officer/core:test': { title: 'Officer', body: 'Test notification', target: 'mobile', route: 'officer://', }, 'officer/core:job': { title: 'Job finished', target: 'mobile', route: 'officer://jobs/{id}', thread: 'officer/core:job', }, /* SendOff. `id` is the FOLDER — a username, or one of your own machine names — and `n` is how many files landed, so the device renders "3 file(s) from andre" and the route opens that directory. A folder name and a count travel, and nothing else: no filename, no path, no size. That is a deliberate relaxation of "content does not travel" rather than an oversight — the owner's call, on the grounds that the file itself never leaves the tailnet, and a notification nobody can act on is not worth sending. A filename would be content; whose folder it is, is addressing. */ 'officer/core:sendoff.landed': { title: 'SendOff', body: '{n} file(s) from {id}', target: 'mobile', route: 'officer://files?path=Documents/SendOff/{id}', thread: 'sendoff:{id}', }, 'officer/core:job-failed': { title: 'Job failed', target: 'mobile', route: 'officer://jobs/{id}', thread: 'officer/core:job', }, /* A commander with something to say. ONE entry rather than one per commander, and the commander travels as `{id}` — so arming a new commander needs no catalogue change and no app resync. `{id}` is the commander's row id, which is what `officer://command-hq/{id}` opens. This entry is what a commander sees when it sends nothing but a code and an id: "Voice Memos" would be a lie without knowing which one, so the generic is honest and the deeplink does the rest. In practice a commander supplies its own `title` and `body`, which override this — that is the exception described in `sidecar/notify/types.ts`, and the whole reason a commander can notify usefully at all. The entry still matters: it is the fallback when it doesn't, and it is what makes the thread group per commander rather than piling every commander into one stack. */ /* A message from the owner, in their own words. The plainest possible entry: both fields are the producer's and this adds nothing around them. It exists because `officer/core:test` has fixed words and `officer/core:commander` prefixes a name, so there was no way to say one sentence to somebody without it arriving dressed as something else. Same trade as every producer-text notification — the words go through Apple and Google. See `sidecar/notify/types.ts`. This one is the case where that is most obviously the point: it is a message TO a person, and a message they cannot read is not one. */ 'officer/core:message': { title: '{title}', body: '{body}', target: 'mobile', route: 'officer://', thread: 'officer/core:message', }, 'officer/core:commander': { // `{title}` is the commander's NAME and `{body}` its sentence — the commander supplies both and this // decides what they look like. It sends "Voice Memos", never "Commander: Voice Memos": the prefix is // the platform's to add, so every commander reads the same on the lock screen whether or not it // remembered to. A commander that sends no title of its own falls through to the bare word. title: 'Commander: {title}', body: '{body}', target: 'mobile', route: 'officer://command-hq/{id}', thread: 'commander:{id}', }, }; /** FNV-1a, 32-bit. Small, stable across processes, and not a security boundary. */ function hash(input: string): number { let h = 0x811c9dc5; for (let i = 0; i < input.length; i++) { h ^= input.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } /** Keep only the fields we publish, so a stray key in a plugin's YAML cannot reach the device. */ function coerce(raw: unknown): NotificationEntry | null { if (raw === null || typeof raw !== 'object') return null; const r = raw as Record; // A title is the one thing a notification cannot do without — the OS needs something to draw. if (typeof r.title !== 'string' || !r.title.trim()) return null; const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v : undefined); return { title: r.title, ...(str(r.body) ? { body: str(r.body)! } : {}), ...(str(r.target) ? { target: str(r.target)! } : {}), ...(str(r.route) ? { route: str(r.route)! } : {}), ...(str(r.thread) ? { thread: str(r.thread)! } : {}), }; } /** * Every plugin-published entry under `root`. * * A file that will not parse is skipped rather than fatal: one plugin shipping broken YAML must not take * out every other plugin's notifications, and a push whose code is missing already has a defined * behaviour on the device (the unknown-code fallback). Failing loudly here would trade a degraded * notification for none at all. */ export function loadPublishedEntries(root: string = NOTIFICATIONS_DIR): Record { let files: string[]; try { files = (readdirSync(root, { recursive: true }) as string[]).filter((f) => /\.ya?ml$/.test(f)); } catch { return {}; // no directory yet is the ordinary state of a fresh install } const entries: Record = {}; for (const rel of files.sort()) { // `//.yaml` — anything shallower has no namespace to derive and is ignored. const parts = rel.split('/'); if (parts.length < 3) continue; const [publisher, appName] = parts; let doc: unknown; try { doc = YAML.parse(readFileSync(join(root, rel), 'utf8')); } catch { continue; } if (doc === null || typeof doc !== 'object') continue; for (const [key, value] of Object.entries(doc as Record)) { const entry = coerce(value); if (!entry) continue; entries[`${publisher}/${appName}:${key}`] = { ...entry, thread: entry.thread ?? `${publisher}/${appName}`, }; } } return entries; } /** * A single plugin's catalogue, read from its OWN directory. * * Separate from `loadPublishedEntries` because the namespace has to come from somewhere else. That * function derives `/` from the path, which worked while installing COPIED a * plugin's yaml to `capabilities/notifications///`. Read in place the path is just * `notifications/catalogue.yaml` and carries no identity at all — so the codes would silently vanish, * which is the failure this exists to prevent. * * The manifest supplies it instead, and that is strictly better provenance: `publisher` and `appName` are * validated at discovery, where the path was only ever a directory somebody chose. The property that * matters is unchanged — the platform imposes the namespace and the yaml cannot claim one. Writing * `officer/core:test` as a key still produces `/:officer/core:test`. */ export function loadPluginEntries(plugin: { appName: string; dir: string; manifest: { publisher: string }; }): Record { const root = join(plugin.dir, 'notifications'); let files: string[]; try { files = (readdirSync(root, { recursive: true }) as string[]).filter((f) => /\.ya?ml$/.test(f)); } catch { return {}; // shipping none is the ordinary case } const { publisher } = plugin.manifest; const entries: Record = {}; for (const rel of files.sort()) { let doc: unknown; try { doc = YAML.parse(readFileSync(join(root, rel), 'utf8')); } catch { continue; } if (doc === null || typeof doc !== 'object') continue; for (const [key, value] of Object.entries(doc as Record)) { const entry = coerce(value); if (!entry) continue; entries[`${publisher}/${plugin.appName}:${key}`] = { ...entry, thread: entry.thread ?? `${publisher}/${plugin.appName}`, }; } } return entries; } /** * The whole catalogue: the platform's own plus everything installed plugins publish. * * Core wins on a collision. A plugin cannot land on a core code by accident — the namespace is derived * from its install path — so this only matters if the store is edited by hand, and the platform's own * notifications are the ones that must keep working when it is. */ export function loadCatalogue(root: string = NOTIFICATIONS_DIR, plugins: PluginCatalogueSource[] = []): Catalogue { // Plugin entries first so the owner's own store, then core, still win a collision — the same precedence // as before, with plugins now read from their own directories rather than copies. const fromPlugins = Object.assign({}, ...plugins.map(loadPluginEntries)); const entries = { ...fromPlugins, ...loadPublishedEntries(root), ...CORE_ENTRIES }; // Sorted so the hash depends on content, not on the order the filesystem happened to return. const canonical = JSON.stringify( Object.keys(entries) .sort() .map((k) => [k, entries[k]]), ); return { version: hash(canonical), entries }; }