the piece that makes a new notification kind a server change rather than an app store release. a push carries a code and integers; this is what the code means, synced to the device instead of compiled into it. deliberately the same shape as affordances rather than a second mechanism: a `notifications/` directory of yaml in the plugin repository, copied to capabilities/notifications/<publisher>/<appName>/ on install, removed on uninstall. publish.ts mirrors affordances/publish.ts closely enough to diff. the namespace is DERIVED from where the file lands, never from what it declares — same rule as mountPrefix. a plugin writing `officer/core:test` as a key gets `<publisher>/<plugin>:officer/core:test` and the real core entry is untouched. that is the security property of the whole scheme and it is pinned by test. version is a content hash, not a counter. a counter needs somewhere to live and is then wrong after a restore, a rollback, or two servers built from the same install; a hash needs no storage and is right in all three. so the client compares mv !== version, and both docs now say so — i had written "higher". core entries ship in code rather than on disk, because capabilities/ is installed rather than committed and `test` has to exist on a machine that has installed nothing — it is what first light is proved with. read from disk per request, not memoised: installing a plugin has to change the answer without a restart, or it presents as "the new plugin's notifications don't work", which is the hardest kind of bug to place. verified live: 401 through the platform, plugin catalogue appearing and disappearing with no restart, version returning to its prior value when the plugin is removed. 10 catalogue tests, 100% coverage of catalogue.ts.
59 lines
2.6 KiB
TypeScript
59 lines
2.6 KiB
TypeScript
import { cp, readdir, rm } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { NOTIFICATIONS_DIR } from './catalogue';
|
|
import type { DiscoveredPlugin } from '../plugins/manifest';
|
|
|
|
// Copying a plugin's notification catalogue into the store the sidecar reads.
|
|
//
|
|
// Deliberately the same shape as `publishAffordances` — a directory in the plugin repository, copied to a
|
|
// platform-managed place on install, removed on uninstall. Two mechanisms for "the plugin ships some
|
|
// files" would drift, and that one is already understood.
|
|
//
|
|
// The destination namespace is what makes codes safe: `<publisher>/<appName>/`, mirroring `mountPrefix`,
|
|
// so a plugin's codes are stamped with its provenance by WHERE IT LANDS rather than by anything it
|
|
// declares. See `catalogue.ts`.
|
|
|
|
/** Where a plugin's catalogue lands. `officerdev/music/`, beside `officer/core`'s built-in entries. */
|
|
export const notificationDirFor = (plugin: DiscoveredPlugin): string =>
|
|
join(NOTIFICATIONS_DIR, plugin.manifest.publisher, plugin.appName);
|
|
|
|
/** What the plugin ships, if anything. */
|
|
export const notificationSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'notifications');
|
|
|
|
/**
|
|
* Publish a plugin's catalogue. Returns the number of files, or 0 when it ships none — the ordinary case.
|
|
*
|
|
* Idempotent by overwriting, so re-running an install republishes rather than erroring. It does NOT remove
|
|
* files the plugin has since dropped; that is uninstall's job, and deleting on every install would make a
|
|
* half-finished copy destructive.
|
|
*/
|
|
export async function publishNotifications(plugin: DiscoveredPlugin): Promise<number> {
|
|
const source = notificationSourceFor(plugin);
|
|
if (!existsSync(source)) return 0;
|
|
|
|
const dest = notificationDirFor(plugin);
|
|
await cp(source, dest, { recursive: true, force: true });
|
|
|
|
try {
|
|
return ((await readdir(dest, { recursive: true })) as string[]).filter((f) => /\.ya?ml$/.test(f)).length;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop a plugin's catalogue.
|
|
*
|
|
* Removes the plugin's own directory, never the publisher's: `officerdev/` holds every first-party
|
|
* plugin's, and taking it out because one was uninstalled would strip the rest.
|
|
*
|
|
* The codes then stop resolving, which is a defined state rather than a broken one — a device holding a
|
|
* queued push for a departed plugin renders the unknown-code fallback. See docs/push-notifications.md.
|
|
*
|
|
* Safe to call when nothing was ever published.
|
|
*/
|
|
export async function unpublishNotifications(plugin: DiscoveredPlugin): Promise<void> {
|
|
await rm(notificationDirFor(plugin), { recursive: true, force: true });
|
|
}
|