Files
platform/src/servers/notifications/catalogue.ts
T
pastilhasandClaude Opus 5 de633f8697 a plain message, in the owner's own words
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. this entry adds nothing around the
producer's title and body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:13:58 +00:00

317 lines
13 KiB
TypeScript

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 `<publisher>/<appName>:<key>`, and the publisher and appName come from WHERE THE FILE IS —
// `notifications/<publisher>/<appName>/*.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<string, NotificationEntry>;
};
/**
* 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<string, NotificationEntry> = {
// ── 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-<app>` 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<string, unknown>;
// 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<string, NotificationEntry> {
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<string, NotificationEntry> = {};
for (const rel of files.sort()) {
// `<publisher>/<appName>/<file>.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<string, unknown>)) {
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 `<publisher>/<appName>` from the path, which worked while installing COPIED a
* plugin's yaml to `capabilities/notifications/<publisher>/<appName>/`. 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 `<you>/<yourplugin>:officer/core:test`.
*/
export function loadPluginEntries(plugin: {
appName: string;
dir: string;
manifest: { publisher: string };
}): Record<string, NotificationEntry> {
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<string, NotificationEntry> = {};
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<string, unknown>)) {
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 };
}