@drift-beacon/plugin 0.2.0 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -19
- package/dist/internal/actions.js +23 -1
- package/dist/internal/models.js +6 -0
- package/dist/internal/peers.js +5 -0
- package/dist/internal/theme.js +75 -0
- package/dist/main.d.ts +8 -2
- package/dist/types.d.ts +72 -4
- package/dist/ui.d.ts +9 -4
- package/dist/ui.js +78 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -201,9 +201,9 @@ Settings items have `name` (unique), `title`, `type` and optional `description`
|
|
|
201
201
|
|
|
202
202
|
`main/src/index.ts` default-exports `definePlugin({ onStart(ctx) { … } })`. The server runs one **instance** for each user and workspace that enabled the plugin; each gets its own `ctx`.
|
|
203
203
|
|
|
204
|
-
- **Start.** `onStart(ctx)` may be async and must finish within 15 seconds. If it throws, the instance doesn't start.
|
|
204
|
+
- **Start.** `onStart(ctx)` may be async and must finish within 15 seconds. Callbacks and routes work as soon as they are registered. If it throws, the instance doesn't start.
|
|
205
205
|
- **Register through `ctx`.** Callbacks, routes and MQTT subscriptions made through `ctx` are released when the instance stops. Keep per-instance state inside `onStart`, never at module scope: the module is shared by every instance.
|
|
206
|
-
- **Stop.** `ctx.onStop(fn)` callbacks run newest first, 5 seconds in total. Afterwards every `ctx` call throws `PluginError` with code `stopped`.
|
|
206
|
+
- **Stop.** `ctx.onStop(fn)` callbacks run newest first, 5 seconds in total, whatever stops the instance (a fault or server shutdown too, even when something else stops it meanwhile). Afterwards every `ctx` call throws `PluginError` with code `stopped`. A server that is killed rather than shut down cuts them short.
|
|
207
207
|
- **Faults.** An uncaught error or unhandled rejection stops only that instance, which is retried after 5, 30 and 120 seconds.
|
|
208
208
|
|
|
209
209
|
| `ctx` member | Main | UI | |
|
|
@@ -214,13 +214,15 @@ Settings items have `name` (unique), `title`, `type` and optional `description`
|
|
|
214
214
|
| `onDataChange` | ✓ | ✓ | The data changed |
|
|
215
215
|
| `sessions.onStarted`, `onEnded`, `onMarked` | ✓ | | Live session events |
|
|
216
216
|
| `storage` | ✓ | ✓ | Key-value storage |
|
|
217
|
-
| `plugins` | ✓ | ✓ | The plugins it uses and itself: status, state and commands
|
|
217
|
+
| `plugins` | ✓ | ✓ | The plugins it uses and itself: status, state, events and commands |
|
|
218
218
|
| `commands`, `events`, `state` | ✓ | | What it provides to other plugins |
|
|
219
219
|
| `mqtt`, `routes`, `log`, `onStop` | ✓ | | Main code only |
|
|
220
220
|
|
|
221
221
|
## UI
|
|
222
222
|
|
|
223
|
-
`connect()` performs a handshake with the web app and resolves to the UI `ctx`. It rejects with `unsupported` when the app doesn't run this UI's API version, and with `unavailable` outside Drift Beacon or after 10 seconds without an answer. `ctx.onDataChange` fires after every update (data, settings, storage, or other plugins' status and state).
|
|
223
|
+
`connect()` performs a handshake with the web app and resolves to the UI `ctx`. It rejects with `unsupported` when the app doesn't run this UI's API version, and with `unavailable` outside Drift Beacon or after 10 seconds without an answer. `ctx.onDataChange` fires after every update (data, settings, storage, or other plugins' status and state; not their events).
|
|
224
|
+
|
|
225
|
+
The app runs a built UI in a sandboxed frame (`allow-scripts` only): it has no storage, cookies, popups or modal dialogs, and a `<form>` never submits. Handle Enter and your button's press yourself instead of relying on a form's `submit`.
|
|
224
226
|
|
|
225
227
|
With React, re-render on every update:
|
|
226
228
|
|
|
@@ -256,6 +258,8 @@ Models keep their identity when their data changes: key `React.memo` and `useMem
|
|
|
256
258
|
const activity = ctx.activities.get(id);
|
|
257
259
|
activity?.category?.name; // links
|
|
258
260
|
activity?.isLive; // the current user has a live session of it
|
|
261
|
+
activity?.isPinned; // the current user has it pinned
|
|
262
|
+
activity?.goal; // { type: "duration", seconds } or { type: "count", count }, or null
|
|
259
263
|
await activity?.track(); // start a span / mark a point
|
|
260
264
|
activity?.data; // the plain row, a new frozen object whenever it changes
|
|
261
265
|
|
|
@@ -277,10 +281,22 @@ ctx.sessions.live({ mine: true });
|
|
|
277
281
|
{activity.iconPath && <svg viewBox="0 0 24 24" fill="currentColor"><path d={activity.iconPath} /></svg>}
|
|
278
282
|
```
|
|
279
283
|
|
|
284
|
+
- From 0.2.2, activities carry their goal, its period and their pins:
|
|
285
|
+
- `goal` is `{ type: "duration", seconds }` or `{ type: "count", count }`, or `null` when the activity has none. A point activity's goal always counts marks; a span activity's counts sessions or totals their duration, as the user chose.
|
|
286
|
+
- `period` is `"day"`, `"week"`, `"month"` or `"year"`: calendar periods from local midnight, weeks starting on Sunday. `null` means progress covers all history. Local means the browser's time zone in a UI, like the app's own progress, but the server's (its `TZ` environment variable) in main code: if the server runs in UTC and the user doesn't, main code's periods are off by the whole difference, all period long. Set `TZ` on the server to the household's zone.
|
|
287
|
+
- `pinnedBy` lists the users who have the activity pinned (each user pins at most one). Main code sees every user's pins, a UI only its own user's. `isPinned` is the current user's pin in both.
|
|
288
|
+
- Progress isn't included: add up `sessions()` in the current period. Drift Beacon counts every member's completed sessions from the period's start (a span counts on the day it started), plus the time so far of the current user's live session.
|
|
289
|
+
- A Drift Beacon older than 0.2.2 provides none of these, to main code as well as to UIs (main code uses the models of the Drift Beacon that runs it): `goal`, `period` and `pinnedBy` are `undefined`, which is why they are optional and how to tell (from 0.2.2, `pinnedBy` is always an array), and `isPinned` is `false` in a UI but `undefined` in main code. Test `isPinned` for truthiness.
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
// The activity to show: the user's live one, else the one they pinned.
|
|
293
|
+
const shown = ctx.sessions.live({ mine: true })[0]?.activity ?? ctx.activities.list().find((a) => a.isPinned);
|
|
294
|
+
```
|
|
295
|
+
|
|
280
296
|
| Activity | |
|
|
281
297
|
|---|---|
|
|
282
|
-
| `id`, `name`, `description`, `trackingType`, `icon`, `iconPath`, `color`, `categoryId`, `archived`, `sortOrder`, `unit` | Fields (`color` is resolved from the category when needed) |
|
|
283
|
-
| `isSpan`, `isPoint`, `isLive`, `category` | Derived values and links |
|
|
298
|
+
| `id`, `name`, `description`, `trackingType`, `icon`, `iconPath`, `color`, `categoryId`, `archived`, `sortOrder`, `unit`, `goal`, `period`, `pinnedBy` | Fields (`color` is resolved from the category when needed) |
|
|
299
|
+
| `isSpan`, `isPoint`, `isLive`, `isPinned`, `category` | Derived values and links |
|
|
284
300
|
| `sessions(filter?)`, `live({ mine? })` | Its sessions |
|
|
285
301
|
| `start()`, `mark()`, `track()`, `end()` | Actions (`end()` ends the current user's live session of it) |
|
|
286
302
|
|
|
@@ -301,7 +317,7 @@ await ctx.sessions.end(sessionId); // or session.end(), activity.end()
|
|
|
301
317
|
await ctx.sessions.discard(sessionId); // or session.discard()
|
|
302
318
|
```
|
|
303
319
|
|
|
304
|
-
Actions run as the current user and resolve once `ctx` shows their result. Starting a session ends the user's other live session, as in the app.
|
|
320
|
+
Actions run as the current user and resolve once `ctx` shows their result. Starting a session ends the user's other live session, as in the app. An id that isn't a non-empty string rejects with `invalid`.
|
|
305
321
|
|
|
306
322
|
| | Live session events | Data change events |
|
|
307
323
|
|---|---|---|
|
|
@@ -314,7 +330,7 @@ Never trigger actions from data changes. Live events fire for everyone's session
|
|
|
314
330
|
|
|
315
331
|
## Storage
|
|
316
332
|
|
|
317
|
-
Per plugin, user and workspace, shared by main code and the UI.
|
|
333
|
+
Per plugin, user and workspace, shared by main code and the UI. Keys are non-empty strings and values must be JSON: anything else rejects with `invalid`, and setting `undefined` removes the key. `set` and `remove` apply at once and resolve when Drift Beacon has accepted the write.
|
|
318
334
|
|
|
319
335
|
```ts
|
|
320
336
|
const faces = ctx.storage.get<Record<string, string>>("faces") ?? {};
|
|
@@ -327,7 +343,7 @@ ctx.storage.onChange((key, value) => { /* changed by main code, a UI or another
|
|
|
327
343
|
|
|
328
344
|
`ctx.config` holds the settings from the manifest's `configuration`. `dbplugin prepare` turns them into types (`.drift-beacon/config.d.ts`), so `ctx.config.mqttTopic` is typed in main code and the UI. A setting is optional unless it is `required` or has a `default`. An instance only runs with valid settings; changing them restarts it.
|
|
329
345
|
|
|
330
|
-
|
|
346
|
+
The app blocks opening a UI when required settings are missing or blank. This presence check does not validate every setting: if saved values fail schema validation, the UI receives defaults under the saved values while main does not run. Check values before relying on them.
|
|
331
347
|
|
|
332
348
|
## MQTT and HTTP routes
|
|
333
349
|
|
|
@@ -341,12 +357,12 @@ ctx.routes.post("webhook", async ({ body }) => ({ status: 202, body: { ok: true
|
|
|
341
357
|
ctx.routes.get("status", () => ({ body: { live: ctx.sessions.live({ mine: true }).length } }));
|
|
342
358
|
```
|
|
343
359
|
|
|
344
|
-
- MQTT uses the workspace's broker; `publish` rejects with `unavailable` without one.
|
|
345
|
-
- Routes are served at `<server>/api/plugins/<plugin id>/api/<name>` (`ctx.plugin.apiPath` is the path up to `/api`). Callers send `Authorization: Bearer <workspace API key>`, which picks the user and workspace. Handlers return `{ status?, body? }`; `body` is sent as JSON.
|
|
360
|
+
- MQTT uses the workspace's broker; `publish` rejects with `unavailable` without one or while it isn't connected, and with `invalid` for an empty topic or a payload that isn't a string (nothing is sent).
|
|
361
|
+
- Routes are served at `<server>/api/plugins/<plugin id>/api/<name>` (`ctx.plugin.apiPath` is the path up to `/api`). Callers send `Authorization: Bearer <workspace API key>`, which picks the user and workspace. Handlers return `{ status?, body? }`; `body` is sent as JSON. While `onStart` runs, a route not registered yet answers 503 (retry after 5 s).
|
|
346
362
|
|
|
347
363
|
## Commands between plugins
|
|
348
364
|
|
|
349
|
-
Main code provides commands; main code and
|
|
365
|
+
Main code provides commands; main code, UIs and Home Assistant run them. A plugin declares the commands it provides in `manifest.json` and handles them; another plugin lists it in `uses` and runs them, as the same user in the same workspace.
|
|
350
366
|
|
|
351
367
|
```jsonc
|
|
352
368
|
// magic-cube/manifest.json
|
|
@@ -371,11 +387,25 @@ const result = await ctx.plugins.get("magic-cube").command("selectPreset", { pre
|
|
|
371
387
|
- It fails fast: `not-installed`, `disabled`, `incompatible` (outside your range) or `unavailable` (not running; it didn't run). `timeout` and `stopped` mean it may have run.
|
|
372
388
|
- `timeoutMs` defaults to 10 s (at most 30 s). A command run inside another continues its chain and shares what's left of its time; the ninth step of a chain is refused with `loop`.
|
|
373
389
|
- From a UI: `ctx.plugins.get(id).command(…)` as in main code. The UI waits the command's time plus 2 s, then rejects `timeout`.
|
|
374
|
-
-
|
|
390
|
+
- In Home Assistant, each command is an action, `drift_beacon.<manifest id>_<command in snake_case>` (`drift_beacon.magic_cube_select_preset`), on a workspace's device: its fields are your input's properties; if you declare `output`, a call on one workspace can ask for the response `{ output }`. It runs as that workspace's user with `meta.caller` `{ kind: "integration" }` and 10 s, and needs no `uses`. Renaming a command or input field breaks automations as it breaks other plugins.
|
|
391
|
+
- `dbplugin prepare` types what you provide (`.drift-beacon/config.d.ts`): `ctx.commands.handle`, `ctx.events.emit` and `ctx.state` take only declared names, with their declared types (none for a kind you declare nothing of).
|
|
392
|
+
- Plugins you use aren't typed for you: keep a copy of the types you need, written from their manifest. `command<T>()` and `state.get<T>()` take the type, and an event's payload is `unknown` until you narrow it. Update the copy along with your `uses` range.
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
// cartridge-reader: what it uses from magic-cube ^1.1.0, kept by hand
|
|
396
|
+
interface SelectPresetOutput {
|
|
397
|
+
activePresetId: string | null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const cube = ctx.plugins.get("magic-cube");
|
|
401
|
+
const { activePresetId } = await cube.command<SelectPresetOutput>("selectPreset", { preset: "Focus" });
|
|
402
|
+
const preset = cube.state.get<string | null>("activePreset");
|
|
403
|
+
cube.onEvent("faceChanged", (face) => showFace(face as number));
|
|
404
|
+
```
|
|
375
405
|
|
|
376
406
|
## Events, state and status between plugins
|
|
377
407
|
|
|
378
|
-
Main code emits and publishes;
|
|
408
|
+
Main code emits and publishes; main code and UIs receive them. A plugin emits the events and publishes the state it declares in `provides`; the plugins that use it (and the plugin itself, through `ctx.plugins.self`) receive them, and see its status.
|
|
379
409
|
|
|
380
410
|
```jsonc
|
|
381
411
|
// magic-cube/manifest.json
|
|
@@ -397,11 +427,13 @@ cube.onStatusChange(({ state, reason }) => ctx.log.info(state, reason ?? ""));
|
|
|
397
427
|
```
|
|
398
428
|
|
|
399
429
|
- `emit` and `set` throw `invalid` for an undeclared name, a value that doesn't match its schema, or over 64 KiB (256 KiB of state in all). Received values are frozen.
|
|
400
|
-
- Events reach listeners registered by then (from `onStart` on); earlier ones are missed. State is kept in memory while the provider runs, cleared when it stops, never persisted.
|
|
430
|
+
- Events reach listeners registered by then (from `onStart` on; in a UI, right after `connect()`); earlier ones are missed. State is kept in memory while the provider runs, cleared when it stops, never persisted.
|
|
431
|
+
- A plugin can emit 50 events a second on average, in bursts up to 100; Drift Beacon drops the rest, with a warning in your console at most every 10 s (each after the first says how many it dropped). For a value that changes faster, publish the latest as state (not limited) or send fewer, bigger events.
|
|
401
432
|
- In main code, a command handler's events and state changes arrive before its caller's `await` resumes.
|
|
402
433
|
- Listeners continue the chain of what caused them; past 8 steps, events are dropped (with a warning) and state changes call no callbacks.
|
|
403
434
|
- `status.state` is `running`, `starting`, `unavailable`, `disabled`, `incompatible` or `not-installed`, with the resolved `version` and a `reason`.
|
|
404
435
|
- In a UI, status and state arrive within about 250 ms (and `ctx.onDataChange` fires), as the latest at each update (changes close together arrive as one), so a command's effects can show just after it resolves: render from `onChange`, or use its output.
|
|
436
|
+
- In a UI (from 0.2.1), events come with those updates, each after the state its plugin set before it. None are replayed (reconnects, reloads, more than about 100 between two updates), every open copy of the UI gets each one, and a command a UI listener runs starts a new chain: render in UI listeners, and react with commands in main code. On an older Drift Beacon they never arrive.
|
|
405
437
|
|
|
406
438
|
## Errors and limits
|
|
407
439
|
|
|
@@ -410,7 +442,7 @@ Operations reject with `PluginError`. Check `error.code`, not `instanceof`:
|
|
|
410
442
|
| Code | Meaning |
|
|
411
443
|
|---|---|
|
|
412
444
|
| `unsupported` | The app doesn't support this API version or request |
|
|
413
|
-
| `invalid` | Bad arguments (tracking a point with `start()`, a value that isn't JSON, an undeclared event, …) |
|
|
445
|
+
| `invalid` | Bad arguments (an empty id or storage key, an MQTT payload that isn't a string, tracking a point with `start()`, a value that isn't JSON, an undeclared event, …) |
|
|
414
446
|
| `not-found` | The activity or session doesn't exist, or the model was removed |
|
|
415
447
|
| `unavailable` | Something needed isn't there: no MQTT broker, no answer from the app |
|
|
416
448
|
| `stopped` | The instance has stopped |
|
|
@@ -429,7 +461,7 @@ Operations reject with `PluginError`. Check `error.code`, not `instanceof`:
|
|
|
429
461
|
|
|
430
462
|
## Developing against your server
|
|
431
463
|
|
|
432
|
-
On a Drift Beacon server you run yourself, set `DEV_PLUGINS_PATH` to the folder that contains your plugin folders and start the server in development mode. It lists each plugin under **Plugins →
|
|
464
|
+
On a Drift Beacon server you run yourself, set `DEV_PLUGINS_PATH` to the folder that contains your plugin folders and start the server in development mode. It lists each plugin under **Plugins → Repositories → Local development**. Enable it in a workspace, then run `npm run dev` in the plugin folder: every save rebuilds main, the server restarts the plugin, and its status, logs and errors (with stack traces pointing at your source) print in your terminal. The UI updates in place.
|
|
433
465
|
|
|
434
466
|
## Releasing a plugin
|
|
435
467
|
|
|
@@ -439,14 +471,31 @@ Plugins are published on GitHub. In a public repository, each immediate child fo
|
|
|
439
471
|
2. Run `npm run release` (or `npx dbplugin pack`), which writes `releases/<id>.zip`.
|
|
440
472
|
3. Create a GitHub release tagged `<id>-<version>` (for example `hello-1.0.0`) and attach `<id>.zip`.
|
|
441
473
|
|
|
442
|
-
Users add the repository URL in **Plugins →
|
|
474
|
+
Users add the repository URL in **Plugins → Repositories** and install from there. Don't commit `dist/` or `releases/`.
|
|
443
475
|
|
|
444
476
|
## Versions
|
|
445
477
|
|
|
446
478
|
- `apiVersion` in the manifest must match the SDK: `dbplugin`, `vite build` and the server all check it.
|
|
447
479
|
- Before 1.0, each minor version (`0.1`, `0.2`) may change the API, and a server runs only its exact version. Install the SDK with `@0.2` to stay on it.
|
|
448
|
-
-
|
|
480
|
+
- Patch releases (`0.2.x`) fix bugs and may add to API 0.2. An addition that needs the app, such as UI events in 0.2.1 or activity goals and pins in 0.2.2, does nothing on an older Drift Beacon. Ignore fields and string values you don't recognise (such as a new `trackingType`).
|
|
449
481
|
|
|
450
482
|
## License
|
|
451
483
|
|
|
452
484
|
MIT
|
|
485
|
+
|
|
486
|
+
## Host theme (SDK 0.2.3)
|
|
487
|
+
|
|
488
|
+
`connect()` installs the host's theme as `--db-*` CSS variables on `<html>` before resolving. It keeps them synchronized without reloading the UI. It also sets `data-color-mode`, `color-scheme`, and the `light`/`dark` class. Use semantic tokens instead of copying the app's palette or forcing a dark class.
|
|
489
|
+
|
|
490
|
+
```css
|
|
491
|
+
body { background: var(--db-background); color: var(--db-foreground); }
|
|
492
|
+
.card { background: var(--db-surface); color: var(--db-surface-foreground); border: 1px solid var(--db-border); }
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
The tokens are `background`, `foreground`, `surface`, `surface-foreground`, `surface-raised`, `muted`, `border`, `accent`, `accent-foreground`, `danger`, `danger-foreground`, `success`, `success-foreground`, `warning`, `warning-foreground`, and `focus`, all prefixed with `--db-`. `background` is the containing surface; `surface` is a card; `surface-raised` is a more prominent surface. Values are complete CSS colors, not HSL channels. Activity and device colors remain domain data, separate from these UI colors.
|
|
496
|
+
|
|
497
|
+
`ctx.theme: UiTheme` is an immutable snapshot (`mode: "light" | "dark"`, `colors: UiThemeColors` using camelCase token names). `ctx.onThemeChange(callback): Unsubscribe` runs after CSS is updated, only when the theme changes. Canvas/chart renderers can subscribe here; theme-only updates do not fire `onDataChange`. Convert CSS colors to sRGB if the rendering library does not accept modern CSS color syntax.
|
|
498
|
+
|
|
499
|
+
Older hosts omit the theme. The SDK then supplies a stable dark fallback palette; an old-host reconnect restores that fallback. Missing theme fields in ordinary state updates leave the current theme unchanged. Invalid theme snapshots are ignored. Older UI bundles ignore the new fields and keep their existing appearance; rebuild and migrate their styles to adopt them.
|
|
500
|
+
|
|
501
|
+
Render the main UI after `connect()` resolves. Give loading/error UI fallback colors, and handle a rejected connection. Framework adapters should map their library's colors to these tokens; the SDK does not require React or HeroUI.
|
package/dist/internal/actions.js
CHANGED
|
@@ -1 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
const nonEmpty = (value, what) => typeof value === "string" && value !== "" ? null : `${what} must be a non-empty string`;
|
|
2
|
+
const ARGUMENTS = {
|
|
3
|
+
startSession: (action) => nonEmpty(action.activityId, "Activity id"),
|
|
4
|
+
markPoint: (action) => nonEmpty(action.activityId, "Activity id"),
|
|
5
|
+
endSession: (action) => nonEmpty(action.sessionId, "Session id"),
|
|
6
|
+
discardSession: (action) => nonEmpty(action.sessionId, "Session id"),
|
|
7
|
+
storageSet: (action) => nonEmpty(action.key, "Storage key"),
|
|
8
|
+
storageRemove: (action) => nonEmpty(action.key, "Storage key"),
|
|
9
|
+
mqttSubscribe: (action) => nonEmpty(action.topic, "MQTT topic"),
|
|
10
|
+
mqttUnsubscribe: (action) => nonEmpty(action.topic, "MQTT topic"),
|
|
11
|
+
mqttPublish: (action) => nonEmpty(action.topic, "MQTT topic") ??
|
|
12
|
+
(typeof action.payload === "string" ? null : "MQTT payload must be a string"),
|
|
13
|
+
};
|
|
14
|
+
export function checkedAction(value) {
|
|
15
|
+
if (typeof value !== "object" || value === null)
|
|
16
|
+
return null;
|
|
17
|
+
const action = value;
|
|
18
|
+
const name = action.name;
|
|
19
|
+
if (typeof name !== "string" || !Object.hasOwn(ARGUMENTS, name))
|
|
20
|
+
return null;
|
|
21
|
+
const problem = ARGUMENTS[name](action);
|
|
22
|
+
return problem === null ? { ok: true, action: action } : { ok: false, problem };
|
|
23
|
+
}
|
package/dist/internal/models.js
CHANGED
|
@@ -11,6 +11,9 @@ const ACTIVITY_FIELDS = [
|
|
|
11
11
|
"archived",
|
|
12
12
|
"sortOrder",
|
|
13
13
|
"unit",
|
|
14
|
+
"goal",
|
|
15
|
+
"period",
|
|
16
|
+
"pinnedBy",
|
|
14
17
|
];
|
|
15
18
|
const CATEGORY_FIELDS = [
|
|
16
19
|
"id",
|
|
@@ -61,6 +64,9 @@ class ActivityModel {
|
|
|
61
64
|
get isLive() {
|
|
62
65
|
return this.live({ mine: true }).length > 0;
|
|
63
66
|
}
|
|
67
|
+
get isPinned() {
|
|
68
|
+
return this.data.pinnedBy?.includes(this.#models.userId) ?? false;
|
|
69
|
+
}
|
|
64
70
|
get category() {
|
|
65
71
|
const categoryId = this.data.categoryId;
|
|
66
72
|
return categoryId === null ? undefined : this.#models.category(categoryId);
|
package/dist/internal/peers.js
CHANGED
|
@@ -6,9 +6,14 @@ export const PEER_LIMITS = {
|
|
|
6
6
|
valueBytes: 64 * 1024,
|
|
7
7
|
stateBytes: 256 * 1024,
|
|
8
8
|
depth: 8,
|
|
9
|
+
eventsPerSecond: 50,
|
|
10
|
+
eventBurst: 100,
|
|
9
11
|
};
|
|
10
12
|
export const COMMAND_TIMEOUT = { defaultMs: 10_000, maxMs: 30_000, minimumMs: 100 };
|
|
11
13
|
export const commandBudgetMs = (timeoutMs) => Math.min(timeoutMs ?? COMMAND_TIMEOUT.defaultMs, COMMAND_TIMEOUT.maxMs);
|
|
14
|
+
export const eventNameProblem = (name) => typeof name === "string" && PEER_NAME.test(name)
|
|
15
|
+
? null
|
|
16
|
+
: `Event names are camelCase letters and digits (got "${String(name)}")`;
|
|
12
17
|
export function commandArgumentsProblem(command, input, options) {
|
|
13
18
|
if (typeof command !== "string" || !command)
|
|
14
19
|
return "Command name must be a non-empty string";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export const THEME_COLOR_KEYS = [
|
|
2
|
+
"background",
|
|
3
|
+
"foreground",
|
|
4
|
+
"surface",
|
|
5
|
+
"surfaceForeground",
|
|
6
|
+
"surfaceRaised",
|
|
7
|
+
"muted",
|
|
8
|
+
"border",
|
|
9
|
+
"accent",
|
|
10
|
+
"accentForeground",
|
|
11
|
+
"danger",
|
|
12
|
+
"dangerForeground",
|
|
13
|
+
"success",
|
|
14
|
+
"successForeground",
|
|
15
|
+
"warning",
|
|
16
|
+
"warningForeground",
|
|
17
|
+
"focus",
|
|
18
|
+
];
|
|
19
|
+
export function fallbackTheme(mode = "dark") {
|
|
20
|
+
const dark = mode === "dark";
|
|
21
|
+
return Object.freeze({
|
|
22
|
+
mode,
|
|
23
|
+
colors: Object.freeze({
|
|
24
|
+
background: dark ? "#09090b" : "#f8f8f8",
|
|
25
|
+
foreground: dark ? "#fafafa" : "#18181b",
|
|
26
|
+
surface: dark ? "#18181b" : "#ffffff",
|
|
27
|
+
surfaceForeground: dark ? "#fafafa" : "#18181b",
|
|
28
|
+
surfaceRaised: dark ? "#27272a" : "#f4f4f5",
|
|
29
|
+
muted: dark ? "#a1a1aa" : "#71717a",
|
|
30
|
+
border: dark ? "#3f3f46" : "#d4d4d8",
|
|
31
|
+
accent: "#2563eb",
|
|
32
|
+
accentForeground: "#ffffff",
|
|
33
|
+
danger: dark ? "#ef4444" : "#b91c1c",
|
|
34
|
+
dangerForeground: "#ffffff",
|
|
35
|
+
success: dark ? "#4ade80" : "#15803d",
|
|
36
|
+
successForeground: dark ? "#052e16" : "#ffffff",
|
|
37
|
+
warning: dark ? "#fbbf24" : "#a16207",
|
|
38
|
+
warningForeground: dark ? "#422006" : "#ffffff",
|
|
39
|
+
focus: "#3b82f6",
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function readTheme(value) {
|
|
44
|
+
if (!value || typeof value !== "object")
|
|
45
|
+
return;
|
|
46
|
+
const { mode, colors } = value;
|
|
47
|
+
if ((mode !== "light" && mode !== "dark") || !colors || typeof colors !== "object")
|
|
48
|
+
return;
|
|
49
|
+
const result = {};
|
|
50
|
+
for (const key of THEME_COLOR_KEYS) {
|
|
51
|
+
const color = colors[key];
|
|
52
|
+
if (typeof color !== "string" ||
|
|
53
|
+
!color.trim() ||
|
|
54
|
+
color.length > 512 ||
|
|
55
|
+
/[;{}]/.test(color) ||
|
|
56
|
+
/(?:var|url)\s*\(/i.test(color))
|
|
57
|
+
return;
|
|
58
|
+
if (typeof CSS !== "undefined" && CSS.supports && !CSS.supports("color", color))
|
|
59
|
+
return;
|
|
60
|
+
result[key] = color;
|
|
61
|
+
}
|
|
62
|
+
return Object.freeze({ mode, colors: Object.freeze(result) });
|
|
63
|
+
}
|
|
64
|
+
export function applyTheme(theme) {
|
|
65
|
+
if (typeof document === "undefined")
|
|
66
|
+
return;
|
|
67
|
+
const root = document.documentElement;
|
|
68
|
+
for (const key of THEME_COLOR_KEYS) {
|
|
69
|
+
root.style.setProperty(`--db-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`, theme.colors[key]);
|
|
70
|
+
}
|
|
71
|
+
root.dataset.colorMode = theme.mode;
|
|
72
|
+
root.style.colorScheme = theme.mode;
|
|
73
|
+
root.classList.toggle("dark", theme.mode === "dark");
|
|
74
|
+
root.classList.toggle("light", theme.mode === "light");
|
|
75
|
+
}
|
package/dist/main.d.ts
CHANGED
|
@@ -19,9 +19,15 @@ export interface MqttMessage {
|
|
|
19
19
|
export interface MqttApi {
|
|
20
20
|
/** Subscribe to a topic filter. `+` matches one level and `#` the remaining levels. */
|
|
21
21
|
subscribe(topic: string, callback: (message: MqttMessage) => void): Unsubscribe;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Rejects with `invalid` when the topic is empty or the payload isn't a string (nothing is sent), and with
|
|
24
|
+
* `unavailable` when the workspace has no broker, the broker isn't connected, or it refuses the message.
|
|
25
|
+
*/
|
|
23
26
|
publish(topic: string, payload: string): Promise<void>;
|
|
24
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* `unavailable` when the workspace has no broker: none is configured, or the server couldn't connect to it when
|
|
29
|
+
* it was saved or when the server started.
|
|
30
|
+
*/
|
|
25
31
|
readonly status: MqttStatus;
|
|
26
32
|
onStatusChange(callback: (status: MqttStatus) => void): Unsubscribe;
|
|
27
33
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -4,11 +4,25 @@
|
|
|
4
4
|
* Workspace data comes as models: an `Activity`, `Category` or `Session` with its fields, links,
|
|
5
5
|
* derived values and actions. Models read the latest snapshot the app sent, synchronously; there is
|
|
6
6
|
* one model per row for the life of a `ctx`, and `model.data` is its plain, frozen row. Later API
|
|
7
|
-
* versions may add fields and new values to string unions such as `trackingType`,
|
|
8
|
-
* ignore what they don't recognise.
|
|
7
|
+
* versions may add fields and new values to string unions such as `trackingType`, `goal.type` and
|
|
8
|
+
* `period`, so plugins must ignore what they don't recognise.
|
|
9
9
|
*/
|
|
10
10
|
/** Stops a subscription. Subscriptions made through `ctx` also stop when the instance stops. */
|
|
11
11
|
export type Unsubscribe = () => void;
|
|
12
|
+
/** An activity's goal: a total duration (seconds) or a number of times, per period. */
|
|
13
|
+
export type ActivityGoal = {
|
|
14
|
+
readonly type: "duration";
|
|
15
|
+
readonly seconds: number;
|
|
16
|
+
} | {
|
|
17
|
+
readonly type: "count";
|
|
18
|
+
readonly count: number;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The calendar period a goal resets over, starting at local midnight (weeks start on Sunday). Local is the browser's
|
|
22
|
+
* time zone in a UI, like the app's own progress, but the server process's (its `TZ`) in main code, which can be hours
|
|
23
|
+
* off the user's for the whole period.
|
|
24
|
+
*/
|
|
25
|
+
export type GoalPeriod = "day" | "week" | "month" | "year";
|
|
12
26
|
/** An activity's plain row: what `activity.data` returns. */
|
|
13
27
|
export interface ActivityData {
|
|
14
28
|
readonly id: string;
|
|
@@ -25,6 +39,24 @@ export interface ActivityData {
|
|
|
25
39
|
/** Position within the activity's category. */
|
|
26
40
|
readonly sortOrder: number;
|
|
27
41
|
readonly unit: string | null;
|
|
42
|
+
/**
|
|
43
|
+
* The activity's goal (from 0.2.2), or null when it has none. A point activity's goal is always a count of marks; a
|
|
44
|
+
* span activity's counts sessions or totals their duration, as the user chose. Progress isn't included: work it out
|
|
45
|
+
* from the sessions in the current `period`. `undefined` when the Drift Beacon running the plugin predates goals
|
|
46
|
+
* (main code and UIs alike).
|
|
47
|
+
*/
|
|
48
|
+
readonly goal?: ActivityGoal | null;
|
|
49
|
+
/**
|
|
50
|
+
* The period progress towards `goal` covers (from 0.2.2), or null when it covers all history. `undefined` when the
|
|
51
|
+
* Drift Beacon running the plugin predates goals.
|
|
52
|
+
*/
|
|
53
|
+
readonly period?: GoalPeriod | null;
|
|
54
|
+
/**
|
|
55
|
+
* Users who have this activity pinned, sorted (from 0.2.2); a user pins at most one activity. Main code sees every
|
|
56
|
+
* user's pins, a UI only its own user's: use `isPinned` for the current user. `undefined` when the Drift Beacon
|
|
57
|
+
* running the plugin predates pins.
|
|
58
|
+
*/
|
|
59
|
+
readonly pinnedBy?: readonly string[];
|
|
28
60
|
}
|
|
29
61
|
/** A category's plain row: what `category.data` returns. */
|
|
30
62
|
export interface CategoryData {
|
|
@@ -63,6 +95,11 @@ export interface Activity extends Readonly<ActivityData>, Model<ActivityData> {
|
|
|
63
95
|
readonly isPoint: boolean;
|
|
64
96
|
/** The current user has a live session of this activity. */
|
|
65
97
|
readonly isLive: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* The current user has this activity pinned (from 0.2.2). Test it for truthiness: main code gets its models from the
|
|
100
|
+
* Drift Beacon running it, and on one older than 0.2.2 this is `undefined` (a UI bundles its own models: false).
|
|
101
|
+
*/
|
|
102
|
+
readonly isPinned: boolean;
|
|
66
103
|
readonly category: Category | undefined;
|
|
67
104
|
/** This activity's sessions, newest first. */
|
|
68
105
|
sessions(filter?: Omit<SessionFilter, "activityId">): readonly Session[];
|
|
@@ -178,13 +215,13 @@ export interface SessionsApi {
|
|
|
178
215
|
/** Data change events. They can arrive in bursts after a sync; don't use them to trigger actions. */
|
|
179
216
|
onChange(callback: (change: Change<Session>) => void): Unsubscribe;
|
|
180
217
|
}
|
|
181
|
-
/** The plugin's own key-value storage, per user and workspace
|
|
218
|
+
/** The plugin's own key-value storage, per user and workspace: non-empty string keys, JSON-compatible values. */
|
|
182
219
|
export interface StorageApi {
|
|
183
220
|
get<T = unknown>(key: string): T | undefined;
|
|
184
221
|
keys(): readonly string[];
|
|
185
222
|
/**
|
|
186
223
|
* Applies locally at once and resolves when Drift Beacon has accepted the write. `undefined` removes the key;
|
|
187
|
-
* a value that isn't JSON-compatible rejects with `invalid`.
|
|
224
|
+
* an empty key or a value that isn't JSON-compatible rejects with `invalid`.
|
|
188
225
|
*/
|
|
189
226
|
set(key: string, value: unknown): Promise<void>;
|
|
190
227
|
/** Applies locally at once and resolves when Drift Beacon has accepted the removal. */
|
|
@@ -237,6 +274,13 @@ export interface PluginPeer {
|
|
|
237
274
|
command<T = unknown>(name: string, input?: Readonly<Record<string, unknown>>, options?: CommandOptions): Promise<T>;
|
|
238
275
|
/** The state it publishes (manifest.json `provides.state`). */
|
|
239
276
|
readonly state: PeerStateApi;
|
|
277
|
+
/**
|
|
278
|
+
* Called for each of its events with this name (manifest.json `provides.events`), with the payload (frozen). In a
|
|
279
|
+
* UI (from 0.2.1): from `connect()` on, with the state its plugin set before it, not replayed, errors logged, and
|
|
280
|
+
* every open copy of the UI gets each one; against an older Drift Beacon it's never called. In main code, see
|
|
281
|
+
* `MainPluginPeer.onEvent`. Throws `invalid` for a name that isn't camelCase.
|
|
282
|
+
*/
|
|
283
|
+
onEvent(name: string, callback: (payload: unknown) => void): Unsubscribe;
|
|
240
284
|
}
|
|
241
285
|
/** The plugins this one uses. */
|
|
242
286
|
export interface PluginsApi {
|
|
@@ -245,4 +289,28 @@ export interface PluginsApi {
|
|
|
245
289
|
/** This plugin itself, as other plugins see it. */
|
|
246
290
|
readonly self: PluginPeer;
|
|
247
291
|
}
|
|
292
|
+
/** Semantic colors supplied by the UI host (SDK 0.2.3). Values are complete CSS colors. */
|
|
293
|
+
export interface UiThemeColors {
|
|
294
|
+
readonly background: string;
|
|
295
|
+
readonly foreground: string;
|
|
296
|
+
readonly surface: string;
|
|
297
|
+
readonly surfaceForeground: string;
|
|
298
|
+
readonly surfaceRaised: string;
|
|
299
|
+
readonly muted: string;
|
|
300
|
+
readonly border: string;
|
|
301
|
+
readonly accent: string;
|
|
302
|
+
readonly accentForeground: string;
|
|
303
|
+
readonly danger: string;
|
|
304
|
+
readonly dangerForeground: string;
|
|
305
|
+
readonly success: string;
|
|
306
|
+
readonly successForeground: string;
|
|
307
|
+
readonly warning: string;
|
|
308
|
+
readonly warningForeground: string;
|
|
309
|
+
readonly focus: string;
|
|
310
|
+
}
|
|
311
|
+
/** An immutable theme snapshot. background is the surface containing this plugin. */
|
|
312
|
+
export interface UiTheme {
|
|
313
|
+
readonly mode: "light" | "dark";
|
|
314
|
+
readonly colors: Readonly<UiThemeColors>;
|
|
315
|
+
}
|
|
248
316
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/ui.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import type { ActivitiesApi, CategoriesApi, PluginConfig, PluginInfo, PluginsApi, SessionsApi, StorageApi, Unsubscribe, UserInfo, WorkspaceInfo } from "./types.ts";
|
|
1
|
+
import type { ActivitiesApi, CategoriesApi, PluginConfig, PluginInfo, PluginsApi, SessionsApi, StorageApi, UiTheme, Unsubscribe, UserInfo, WorkspaceInfo } from "./types.ts";
|
|
2
2
|
export { PluginError } from "./errors.ts";
|
|
3
3
|
export type * from "./types.ts";
|
|
4
4
|
export { API_VERSION } from "./version.ts";
|
|
5
5
|
/** Everything a plugin UI can use. */
|
|
6
6
|
export interface UiContext {
|
|
7
|
+
/** Current host theme; SDK 0.2.3 applies --db-* variables before connect resolves. Older hosts use a dark fallback. */
|
|
8
|
+
readonly theme: UiTheme;
|
|
9
|
+
/** Theme-only updates do not fire onDataChange. CSS updates before this callback runs. */
|
|
10
|
+
onThemeChange(callback: (theme: UiTheme) => void): Unsubscribe;
|
|
7
11
|
/** The installed plugin; it can change while the UI is open, for example to a new version. */
|
|
8
12
|
readonly plugin: PluginInfo;
|
|
9
13
|
readonly user: UserInfo;
|
|
@@ -15,13 +19,14 @@ export interface UiContext {
|
|
|
15
19
|
readonly sessions: SessionsApi;
|
|
16
20
|
/**
|
|
17
21
|
* Called after each update from the app (workspace data, config, storage, or other plugins' status and state) and
|
|
18
|
-
* after local storage writes.
|
|
22
|
+
* after local storage writes; not for other plugins' events.
|
|
19
23
|
*/
|
|
20
24
|
onDataChange(callback: () => void): Unsubscribe;
|
|
21
25
|
readonly storage: StorageApi;
|
|
22
26
|
/**
|
|
23
|
-
* Its own plugin and the plugins it uses: their status and published state (within about 250 ms of a change),
|
|
24
|
-
* their commands. A command's effects on their state, storage or data
|
|
27
|
+
* Its own plugin and the plugins it uses: their status and published state (within about 250 ms of a change),
|
|
28
|
+
* their events (with them), and their commands. A command's effects on their state, storage or data, and its events,
|
|
29
|
+
* can arrive just after it resolves.
|
|
25
30
|
*/
|
|
26
31
|
readonly plugins: PluginsApi;
|
|
27
32
|
}
|
package/dist/ui.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { PluginError } from "./errors.js";
|
|
2
|
+
import { checkedAction } from "./internal/actions.js";
|
|
2
3
|
import { Collection } from "./internal/collection.js";
|
|
3
4
|
import { deepFreeze } from "./internal/freeze.js";
|
|
4
5
|
import { jsonEqual } from "./internal/json-equal.js";
|
|
5
6
|
import { WorkspaceModels } from "./internal/models.js";
|
|
6
|
-
import { commandArgumentsProblem, commandBudgetMs, samePluginStatus } from "./internal/peers.js";
|
|
7
|
+
import { commandArgumentsProblem, commandBudgetMs, eventNameProblem, samePluginStatus } from "./internal/peers.js";
|
|
7
8
|
import { isUiMessage, UI_CHANNEL, } from "./internal/protocol.js";
|
|
9
|
+
import { applyTheme, fallbackTheme, readTheme } from "./internal/theme.js";
|
|
8
10
|
import { API_VERSION } from "./version.js";
|
|
9
11
|
export { PluginError } from "./errors.js";
|
|
10
12
|
export { API_VERSION } from "./version.js";
|
|
@@ -79,6 +81,8 @@ function open(timeoutMs) {
|
|
|
79
81
|
class UiClient {
|
|
80
82
|
post;
|
|
81
83
|
context;
|
|
84
|
+
theme;
|
|
85
|
+
themeListeners = new Set();
|
|
82
86
|
config;
|
|
83
87
|
plugin;
|
|
84
88
|
rows = {
|
|
@@ -106,6 +110,8 @@ class UiClient {
|
|
|
106
110
|
peerObjects = new Map();
|
|
107
111
|
constructor(welcome, post) {
|
|
108
112
|
this.post = post;
|
|
113
|
+
this.theme = readTheme(welcome.theme) ?? fallbackTheme();
|
|
114
|
+
applyTheme(this.theme);
|
|
109
115
|
this.config = welcome.config;
|
|
110
116
|
this.plugin = welcome.plugin;
|
|
111
117
|
this.peersShared = welcome.peers !== undefined;
|
|
@@ -127,11 +133,11 @@ class UiClient {
|
|
|
127
133
|
this.applyData(welcome.data);
|
|
128
134
|
const apis = this.models.apis();
|
|
129
135
|
const self = this;
|
|
130
|
-
const subscribe = (listeners, callback) => {
|
|
131
|
-
listeners.add(callback);
|
|
132
|
-
return () => listeners.delete(callback);
|
|
133
|
-
};
|
|
134
136
|
this.context = {
|
|
137
|
+
get theme() {
|
|
138
|
+
return self.theme;
|
|
139
|
+
},
|
|
140
|
+
onThemeChange: (callback) => subscribe(self.themeListeners, callback),
|
|
135
141
|
get plugin() {
|
|
136
142
|
return self.plugin;
|
|
137
143
|
},
|
|
@@ -178,10 +184,6 @@ class UiClient {
|
|
|
178
184
|
if (existing)
|
|
179
185
|
return existing;
|
|
180
186
|
const view = this.viewOf(id);
|
|
181
|
-
const subscribe = (listeners, callback) => {
|
|
182
|
-
listeners.add(callback);
|
|
183
|
-
return () => listeners.delete(callback);
|
|
184
|
-
};
|
|
185
187
|
const peer = Object.freeze({
|
|
186
188
|
id,
|
|
187
189
|
get status() {
|
|
@@ -194,6 +196,20 @@ class UiClient {
|
|
|
194
196
|
keys: () => [...view.state.keys()],
|
|
195
197
|
onChange: (callback) => subscribe(view.stateListeners, callback),
|
|
196
198
|
}),
|
|
199
|
+
onEvent: (name, callback) => {
|
|
200
|
+
const invalidName = eventNameProblem(name);
|
|
201
|
+
if (invalidName)
|
|
202
|
+
throw new PluginError("invalid", invalidName);
|
|
203
|
+
const problem = this.unreachable(id);
|
|
204
|
+
if (problem)
|
|
205
|
+
throw problem;
|
|
206
|
+
let listeners = view.eventListeners.get(name);
|
|
207
|
+
if (!listeners) {
|
|
208
|
+
listeners = new Set();
|
|
209
|
+
view.eventListeners.set(name, listeners);
|
|
210
|
+
}
|
|
211
|
+
return subscribe(listeners, callback);
|
|
212
|
+
},
|
|
197
213
|
});
|
|
198
214
|
this.peerObjects.set(id, peer);
|
|
199
215
|
return peer;
|
|
@@ -206,7 +222,13 @@ class UiClient {
|
|
|
206
222
|
viewOf(id) {
|
|
207
223
|
let view = this.peerViews.get(id);
|
|
208
224
|
if (!view) {
|
|
209
|
-
view = {
|
|
225
|
+
view = {
|
|
226
|
+
status: NO_PEERS,
|
|
227
|
+
state: new Map(),
|
|
228
|
+
statusListeners: new Set(),
|
|
229
|
+
stateListeners: new Set(),
|
|
230
|
+
eventListeners: new Map(),
|
|
231
|
+
};
|
|
210
232
|
this.peerViews.set(id, view);
|
|
211
233
|
}
|
|
212
234
|
return view;
|
|
@@ -270,6 +292,7 @@ class UiClient {
|
|
|
270
292
|
receive(message) {
|
|
271
293
|
if (message.type === "welcome") {
|
|
272
294
|
this.plugin = message.plugin;
|
|
295
|
+
this.updateTheme(readTheme(message.theme) ?? fallbackTheme());
|
|
273
296
|
this.receive({
|
|
274
297
|
channel: message.channel,
|
|
275
298
|
type: "state",
|
|
@@ -291,8 +314,17 @@ class UiClient {
|
|
|
291
314
|
pending.resolve(message.result);
|
|
292
315
|
return;
|
|
293
316
|
}
|
|
317
|
+
if (message.type === "event") {
|
|
318
|
+
this.applyEvent(message);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
294
321
|
if (message.type !== "state")
|
|
295
322
|
return;
|
|
323
|
+
if (message.theme) {
|
|
324
|
+
const theme = readTheme(message.theme);
|
|
325
|
+
if (theme)
|
|
326
|
+
this.updateTheme(theme);
|
|
327
|
+
}
|
|
296
328
|
if (message.config)
|
|
297
329
|
this.config = message.config;
|
|
298
330
|
if (message.data)
|
|
@@ -304,7 +336,25 @@ class UiClient {
|
|
|
304
336
|
}
|
|
305
337
|
if (message.peers)
|
|
306
338
|
this.applyPeers(message.peers);
|
|
307
|
-
|
|
339
|
+
if (message.config || message.data || message.storage || message.peers) {
|
|
340
|
+
notify(this.dataListeners, (listener) => listener());
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
updateTheme(theme) {
|
|
344
|
+
if (jsonEqual(this.theme, theme))
|
|
345
|
+
return;
|
|
346
|
+
this.theme = theme;
|
|
347
|
+
applyTheme(theme);
|
|
348
|
+
notify(this.themeListeners, (listener) => listener(theme));
|
|
349
|
+
}
|
|
350
|
+
applyEvent({ plugin, event, payload }) {
|
|
351
|
+
if (!this.listed.has(plugin))
|
|
352
|
+
return;
|
|
353
|
+
const listeners = this.peerViews.get(plugin)?.eventListeners.get(event);
|
|
354
|
+
if (!listeners?.size)
|
|
355
|
+
return;
|
|
356
|
+
const frozen = deepFreeze(payload);
|
|
357
|
+
notify(listeners, (listener) => listener(frozen));
|
|
308
358
|
}
|
|
309
359
|
applyData(data) {
|
|
310
360
|
const changes = this.models.update({
|
|
@@ -341,6 +391,9 @@ class UiClient {
|
|
|
341
391
|
return changed;
|
|
342
392
|
}
|
|
343
393
|
async write(key, value) {
|
|
394
|
+
const checked = checkedAction(value === undefined ? { name: "storageRemove", key } : { name: "storageSet", key, value });
|
|
395
|
+
if (checked?.ok === false)
|
|
396
|
+
throw new PluginError("invalid", checked.problem);
|
|
344
397
|
const writeId = ++this.nextWrite;
|
|
345
398
|
this.pendingWrites.set(key, (this.pendingWrites.get(key) ?? 0) + 1);
|
|
346
399
|
this.writesSincePush.set(key, writeId);
|
|
@@ -385,13 +438,24 @@ class UiClient {
|
|
|
385
438
|
});
|
|
386
439
|
}
|
|
387
440
|
}
|
|
441
|
+
function subscribe(listeners, callback) {
|
|
442
|
+
if (typeof callback !== "function")
|
|
443
|
+
throw new PluginError("invalid", "Callback must be a function");
|
|
444
|
+
listeners.add(callback);
|
|
445
|
+
return () => listeners.delete(callback);
|
|
446
|
+
}
|
|
447
|
+
const logListenerError = (error) => console.error("[drift-beacon] Error in plugin listener:", error);
|
|
388
448
|
function notify(listeners, call) {
|
|
389
|
-
for (const listener of listeners) {
|
|
449
|
+
for (const listener of [...listeners]) {
|
|
450
|
+
if (!listeners.has(listener))
|
|
451
|
+
continue;
|
|
390
452
|
try {
|
|
391
|
-
call(listener);
|
|
453
|
+
const result = call(listener);
|
|
454
|
+
if (result && typeof result.then === "function")
|
|
455
|
+
result.then(undefined, logListenerError);
|
|
392
456
|
}
|
|
393
457
|
catch (error) {
|
|
394
|
-
|
|
458
|
+
logListenerError(error);
|
|
395
459
|
}
|
|
396
460
|
}
|
|
397
461
|
}
|
package/package.json
CHANGED