notification catalogue: GET /api/notify/mapping
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.
This commit is contained in:
@@ -98,13 +98,19 @@ credential → APNs → device → banner.
|
||||
GET /api/notify/mapping → { version: 47, entries: { "<code>": { title, body?, target, route, thread } } }
|
||||
```
|
||||
|
||||
_(Not built yet — I'm writing it now. The shape is fixed; assume it and I'll match it.)_
|
||||
**Built and live** as of 2026-08-19. `Cache-Control: no-store` — the client decides by `version`, so a
|
||||
cached copy would defeat the sync it drives. The catalogue is re-read from disk per request, so installing
|
||||
a plugin changes the response with no restart.
|
||||
|
||||
Codes are namespaced `<publisher>/<appName>:<code>`, e.g. `officerdev/music:12.3`.
|
||||
|
||||
The app **fetches this and caches it into a shared App Group container** — shared because the notification
|
||||
extension in Phase 3 must read it without the app running. Sync on launch, and whenever a push arrives
|
||||
carrying an `mv` higher than the cached `version`.
|
||||
whose `mv` **differs from** the cached `version`.
|
||||
|
||||
**Compare with `!==`, not `>`.** `version` is a content hash, not a counter, so it does not increase
|
||||
monotonically — removing a plugin returns it to the value it had before that plugin was installed. That is
|
||||
deliberate: a counter would need persistent storage and would be wrong after a restore or a rollback.
|
||||
|
||||
Entries are templates: `title: "{n} new emails"` rendered against the push's `n`. `route` carries the deep
|
||||
link with `{id}` interpolated.
|
||||
|
||||
@@ -130,18 +130,27 @@ An entry says how to render and where to go:
|
||||
| `route` | the deep link, with `{id}` interpolated |
|
||||
| `thread` | grouping key, so like-notifications collapse rather than stack |
|
||||
|
||||
**Plugins declare their own**, on the affordances pattern (`src/servers/affordances/publish.ts`): a
|
||||
directory in the plugin repository, copied to a platform-managed store on install, removed on uninstall,
|
||||
namespaced `<publisher>/<appName>/`. That mechanism already exists and is understood; a second one for
|
||||
**Plugins declare their own**, on the affordances pattern (`src/servers/notifications/publish.ts`, which
|
||||
deliberately mirrors `affordances/publish.ts`): a `notifications/` directory of YAML in the plugin
|
||||
repository, copied to `capabilities/notifications/<publisher>/<appName>/` on install and removed on
|
||||
uninstall.
|
||||
|
||||
The namespace is **derived from where the file lands, never from what it declares** — the same rule as
|
||||
`mountPrefix`. A plugin therefore cannot claim `officer/core:` or another publisher's codes by writing
|
||||
them into its own catalogue; it would have to install itself somewhere it cannot. This is pinned by test. That mechanism already exists and is understood; a second one for
|
||||
notifications would drift from it. The platform aggregates every installed plugin's catalogue plus its own
|
||||
into the response above, and `version` increments whenever that aggregate changes.
|
||||
|
||||
### Staying in sync, and the fallback
|
||||
|
||||
Every push carries `mv`, the mapping version it was rendered against. The app compares it to its cached
|
||||
version and **resyncs in the background when it is behind** — so the mapping normally catches up before an
|
||||
version and **resyncs in the background when they differ** — so the mapping normally catches up before an
|
||||
unknown code can arrive.
|
||||
|
||||
`version` is a **content hash, not a counter**, so the test is `mv !== version` rather than `mv > version`.
|
||||
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; a hash needs no storage and is right in all three.
|
||||
|
||||
When a push still outruns the sync, the fallback renders:
|
||||
|
||||
> **Unknown notification**
|
||||
|
||||
@@ -119,6 +119,37 @@ normalised into one. A plugin ships an
|
||||
`affordances/` directory in its repository; installing copies it in, uninstalling removes it — the same
|
||||
shape as `assets/` and `publishAssets`.
|
||||
|
||||
## Notifications — the same shape, for push
|
||||
|
||||
A plugin can also declare what its push notifications MEAN. Same mechanism, different store:
|
||||
|
||||
```
|
||||
capabilities/notifications/
|
||||
officerdev/music/
|
||||
catalogue.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
nowPlaying:
|
||||
title: 'Now playing'
|
||||
route: 'officer://music/{id}'
|
||||
```
|
||||
|
||||
That becomes the code `officerdev/music:nowPlaying`. A push carries the code and integers — never content —
|
||||
and the device renders the title and follows the route. The point is that **adding a notification is a
|
||||
server change and never an app build**, because the mapping is synced to the device rather than compiled
|
||||
into it.
|
||||
|
||||
**The namespace comes from where the file lands, not from what it says.** Writing `officer/core:test` as a
|
||||
key in your own catalogue produces `<you>/<yourplugin>:officer/core:test`, not the core code — the same
|
||||
rule as `mountPrefix`, and pinned by test in `src/servers/notifications/catalogue.test.ts`.
|
||||
|
||||
An entry needs a `title`; `body`, `target`, `route` and `thread` are optional, `thread` defaults to your
|
||||
namespace, and unknown keys are dropped rather than forwarded to the device. Broken YAML in one plugin's
|
||||
catalogue is skipped without affecting anyone else's.
|
||||
|
||||
See `docs/push-notifications.md`.
|
||||
|
||||
### A plugin-contributed one, for reference
|
||||
|
||||
This is the gitea plugin's, and it shows what these are for: the agent cannot discover from the
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadCatalogue, loadPublishedEntries } from './catalogue';
|
||||
|
||||
/** A throwaway `notifications/` store: `write('officerdev/music/a.yaml', '…')`. */
|
||||
function store(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'notif-'));
|
||||
for (const [rel, body] of Object.entries(files)) {
|
||||
const abs = join(root, rel);
|
||||
mkdirSync(join(abs, '..'), { recursive: true });
|
||||
writeFileSync(abs, body);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe('notification catalogue', () => {
|
||||
test('a code is namespaced by WHERE the file is, not by what it says', () => {
|
||||
// The security property. A plugin declaring `officer/core:job` inside its own file must not be able
|
||||
// to overwrite the platform's, because the namespace comes from its install path.
|
||||
const root = store({
|
||||
'officerdev/music/c.yaml': ['nowPlaying:', ' title: Now playing'].join('\n'),
|
||||
});
|
||||
try {
|
||||
const entries = loadPublishedEntries(root);
|
||||
expect(Object.keys(entries)).toEqual(['officerdev/music:nowPlaying']);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a plugin cannot claim a core code', () => {
|
||||
// Even spelled out verbatim as a key, it lands under the plugin's own namespace...
|
||||
const root = store({
|
||||
'evil/plugin/c.yaml': ['"officer/core:test":', ' title: Pwned'].join('\n'),
|
||||
});
|
||||
try {
|
||||
const entries = loadPublishedEntries(root);
|
||||
expect(entries['officer/core:test']).toBeUndefined();
|
||||
expect(entries['evil/plugin:officer/core:test']?.title).toBe('Pwned');
|
||||
|
||||
// ...and the real core entry is untouched in the assembled catalogue.
|
||||
expect(loadCatalogue(root).entries['officer/core:test']?.body).toBe('Test notification');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('core entries survive a hand-edited store that collides with them', () => {
|
||||
const root = store({
|
||||
'officer/core/c.yaml': ['test:', ' title: Replaced'].join('\n'),
|
||||
});
|
||||
try {
|
||||
expect(loadCatalogue(root).entries['officer/core:test']?.title).toBe('Officer');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('one plugin shipping broken yaml does not take out the others', () => {
|
||||
const root = store({
|
||||
'officerdev/music/c.yaml': ['a:', ' title: Music'].join('\n'),
|
||||
'officerdev/photos/c.yaml': '{{{ not yaml at all',
|
||||
'officerdev/email/c.yaml': ['b:', ' title: Mail'].join('\n'),
|
||||
});
|
||||
try {
|
||||
const entries = loadPublishedEntries(root);
|
||||
expect(entries['officerdev/music:a']?.title).toBe('Music');
|
||||
expect(entries['officerdev/email:b']?.title).toBe('Mail');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown keys are dropped, and an entry with no title is skipped entirely', () => {
|
||||
const root = store({
|
||||
'officerdev/music/c.yaml': [
|
||||
'ok:',
|
||||
' title: Fine',
|
||||
' sneaky: reaches-the-device',
|
||||
'noTitle:',
|
||||
' body: orphan',
|
||||
].join('\n'),
|
||||
});
|
||||
try {
|
||||
const entries = loadPublishedEntries(root);
|
||||
expect(entries['officerdev/music:ok']).toEqual({ title: 'Fine', thread: 'officerdev/music' });
|
||||
expect(entries['officerdev/music:noTitle']).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('thread defaults to the namespace so like-notifications collapse', () => {
|
||||
const root = store({
|
||||
'officerdev/music/c.yaml': ['a:', ' title: A', 'b:', ' title: B', ' thread: own'].join('\n'),
|
||||
});
|
||||
try {
|
||||
const entries = loadPublishedEntries(root);
|
||||
expect(entries['officerdev/music:a']?.thread).toBe('officerdev/music');
|
||||
expect(entries['officerdev/music:b']?.thread).toBe('own');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a file too shallow to have a namespace is ignored', () => {
|
||||
const root = store({ 'loose.yaml': ['x:', ' title: Nope'].join('\n') });
|
||||
try {
|
||||
expect(loadPublishedEntries(root)).toEqual({});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a missing store is the ordinary fresh install, not an error', () => {
|
||||
const catalogue = loadCatalogue(join(tmpdir(), 'definitely-not-here-notif'));
|
||||
expect(catalogue.entries['officer/core:test']).toBeDefined();
|
||||
expect(catalogue.version).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('version is a content hash: stable across reads, and it moves when content does', () => {
|
||||
const a = store({ 'officerdev/music/c.yaml': ['a:', ' title: A'].join('\n') });
|
||||
const b = store({ 'officerdev/music/c.yaml': ['a:', ' title: B'].join('\n') });
|
||||
try {
|
||||
// Stable — the client resyncs on `mv !== version`, so a version that drifted per call would make
|
||||
// every push look like a mapping change.
|
||||
expect(loadCatalogue(a).version).toBe(loadCatalogue(a).version);
|
||||
expect(loadCatalogue(a).version).not.toBe(loadCatalogue(b).version);
|
||||
} finally {
|
||||
rmSync(a, { recursive: true, force: true });
|
||||
rmSync(b, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('version does not depend on filesystem ordering', () => {
|
||||
const a = store({
|
||||
'officerdev/music/1.yaml': ['a:', ' title: A'].join('\n'),
|
||||
'officerdev/music/2.yaml': ['b:', ' title: B'].join('\n'),
|
||||
});
|
||||
const b = store({
|
||||
'officerdev/music/2.yaml': ['b:', ' title: B'].join('\n'),
|
||||
'officerdev/music/1.yaml': ['a:', ' title: A'].join('\n'),
|
||||
});
|
||||
try {
|
||||
expect(loadCatalogue(a).version).toBe(loadCatalogue(b).version);
|
||||
} finally {
|
||||
rmSync(a, { recursive: true, force: true });
|
||||
rmSync(b, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
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. */
|
||||
thread?: 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> = {
|
||||
'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',
|
||||
},
|
||||
'officer/core:job-failed': {
|
||||
title: 'Job failed',
|
||||
target: 'mobile',
|
||||
route: 'officer://jobs/{id}',
|
||||
thread: 'officer/core:job',
|
||||
},
|
||||
'officer/core:agent': {
|
||||
title: 'Agent finished',
|
||||
target: 'mobile',
|
||||
route: 'officer://chat/{id}',
|
||||
thread: 'officer/core:agent',
|
||||
},
|
||||
};
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): Catalogue {
|
||||
const entries = { ...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 };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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 });
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { fetchPlugin } from './fetch';
|
||||
import { revokeGrantsForPermissions } from 'officerdb';
|
||||
import { publishAssets, unpublishAssets } from '../app-store/assets';
|
||||
import { publishAffordances, unpublishAffordances } from '../affordances/publish';
|
||||
import { publishNotifications, unpublishNotifications } from '../notifications/publish';
|
||||
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||
import { generatePluginSchemas, pushPluginSchema } from './schema';
|
||||
import { declaredTables, dropTables, existingTables } from './tables';
|
||||
@@ -361,6 +362,17 @@ export async function installPlugin(
|
||||
await step(steps, onStep, `affordances: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// Same bargain for the notification catalogue: a plugin that cannot publish its codes still installs,
|
||||
// and its pushes render the unknown-code fallback on the device rather than nothing at all.
|
||||
try {
|
||||
const published = await publishNotifications(plugin);
|
||||
if (published) {
|
||||
await step(steps, onStep, `notifications: ${published} published to capabilities/notifications/`);
|
||||
}
|
||||
} catch (err) {
|
||||
await step(steps, onStep, `notifications: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only
|
||||
// offers a tile for a plugin that has a frontend — a backend-only plugin has no screen to pin.
|
||||
if (plugin.web && forUserId !== undefined) {
|
||||
@@ -450,7 +462,10 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?:
|
||||
// being told about a surface that is no longer there — an affordance for an uninstalled plugin is
|
||||
// worse than none, because it reads as true.
|
||||
if (plugin) await unpublishAffordances(plugin).catch(() => {});
|
||||
await step(steps, onStep, 'tables and data: untouched (published assets and affordances removed)');
|
||||
// And its notification codes, which stop resolving rather than resolving to something stale. A device
|
||||
// still holding a queued push renders the unknown-code fallback, which is a defined state.
|
||||
if (plugin) await unpublishNotifications(plugin).catch(() => {});
|
||||
await step(steps, onStep, 'tables and data: untouched (published assets, affordances and notifications removed)');
|
||||
return { ok: true, appName, steps };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { dispatch, configuredChannels } from './dispatch';
|
||||
import { handleDeviceRoute } from './devices';
|
||||
import { loadCatalogue } from '../../notifications/catalogue';
|
||||
import { resolveNotifyUser } from './resolve-user';
|
||||
import { closeApnsSessions } from './apns';
|
||||
import type { Notification, NotifyType } from './types';
|
||||
@@ -22,6 +23,7 @@ import { API_URL } from '../../officer-url.mjs';
|
||||
// POST /_officer/devices register/refresh a device (idempotent; call every launch)
|
||||
// DELETE /_officer/devices/:token deregister, on sign-out
|
||||
// GET /_officer/devices the caller's registered devices
|
||||
// GET /mapping the code-to-text catalogue the device caches
|
||||
//
|
||||
// The notify body is deliberately narrow: a category and an id, never content. See ./text.ts for why
|
||||
// the visible string is composed here rather than sent by the producer.
|
||||
@@ -50,6 +52,18 @@ const server = Bun.serve({
|
||||
return Response.json({ ok: true, channels: configuredChannels() });
|
||||
}
|
||||
|
||||
// The code-to-text mapping the device caches. Read from disk on every request rather than memoised:
|
||||
// installing a plugin changes it, and a notifier that serves a stale catalogue until someone restarts
|
||||
// it would present as "the new plugin's notifications don't work" — the hardest kind of bug to place.
|
||||
// It is a few kilobytes off local disk against a request the app makes about once per launch.
|
||||
if (url.pathname === '/mapping' && req.method === 'GET') {
|
||||
const catalogue = loadCatalogue();
|
||||
return Response.json(catalogue, {
|
||||
// The client decides by `version`, so a cached copy would defeat the sync it exists to drive.
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/_officer/notify' && req.method === 'POST') {
|
||||
let body: Partial<Notification>;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user