@ham2k/extension-sdk 0.1.0 → 0.3.0

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.
@@ -0,0 +1,445 @@
1
+ # Distributing an extension — the `.h2kext` bundle
2
+
3
+ An extension can be handed to someone as a single file they install into the
4
+ app: no registry, no app-store release. This describes the format and the tool
5
+ that writes one.
6
+
7
+ Writing the extension in the first place is the dev loop's job —
8
+ [development.md](https://github.com/ham2k/halo/blob/main/docs/extensions/development.md).
9
+
10
+ ## The format
11
+
12
+ A `.h2kext` is a **zip**:
13
+
14
+ ```
15
+ manifest.json required — identity and capabilities
16
+ index.js required — your extension, built
17
+ assets/ optional — icons, data, licence text
18
+ ```
19
+
20
+ Zip rather than tar.gz for one reason above the others: the host must read
21
+ `manifest.json` **without** running any JS and often without unpacking the
22
+ rest — to list an installed-but-disabled extension, and to show someone what a
23
+ file is asking for *before* they agree to install it. A zip's central
24
+ directory makes that a seek; a tar.gz is a solid stream that must be
25
+ decompressed from the beginning every time. Signed-archive precedent (`.vsix`,
26
+ `.xpi`, `.crx`, `.jar`, `.epub` are all zip) and being openable by
27
+ double-click on macOS and Windows settle the rest.
28
+
29
+ ### manifest.json
30
+
31
+ The same manifest a built-in extension carries, plus `api`:
32
+
33
+ ```json
34
+ {
35
+ "key": "w7abc-notes",
36
+ "name": "W7ABC Notes",
37
+ "version": "1.2.3",
38
+ "description": "Notes about the stations I work",
39
+ "category": "dashboard",
40
+ "api": 1,
41
+ "domains": ["notes.example.org"],
42
+ "icon": "note-text"
43
+ }
44
+ ```
45
+
46
+ `api` is the extension API the bundle was built against. The host refuses one
47
+ it does not speak, so an older app meeting a newer bundle says so instead of
48
+ failing somewhere deep in a hook.
49
+
50
+ Three fields are **refused** in a distributed bundle, and it is worth
51
+ understanding why:
52
+
53
+ - **No `category`** makes an extension *core* to the host: always enabled, and
54
+ never listed in the Extensions panel. That is a status the app grants its own
55
+ extensions. A distributed bundle claiming it would be one the user could
56
+ neither see nor turn off.
57
+ - **`experiments`** gates availability against the *app's* experiment catalog.
58
+ A key the host doesn't define hides the extension permanently, with nothing
59
+ in the UI to explain it.
60
+ - **`enabledByDefault`** is a shipping app's answer for the extensions it
61
+ carries whether the operator asked for them or not. An installed bundle is
62
+ one the operator went and got, so the answer is always yes, and a manifest
63
+ saying otherwise describes a state it cannot reach.
64
+
65
+ **`geo` is now `relevance`**, and a manifest still carrying the old name is
66
+ refused at packing and at publishing rather than ignored. Nothing reads
67
+ `geo`, and no check anywhere looks at unknown top-level keys, so accepting it
68
+ would publish a regional extension as a worldwide one and tell its author
69
+ nothing. Rename the key; the four geographic lists inside it are unchanged.
70
+
71
+ The app itself does NOT refuse either of these at install. The packer and the
72
+ catalog answer to the author, who can fix the manifest and publish again;
73
+ refusing at the door tells the operator instead, who can do nothing about a
74
+ published manifest but go without an extension that would have worked — and
75
+ every bundle published before these rules existed carries one. Neither costs
76
+ them anything: an installed extension is enabled because they installed it,
77
+ and an unread `geo` makes it worldwide rather than regional. What the app
78
+ refuses is what it cannot run.
79
+
80
+ ### Relevance
81
+
82
+ `relevance` says where, when and for whom the extension matters. The catalog
83
+ ranks its listing by it, for the operators the extension was written for, and
84
+ the Extensions panel's Catalog section shows the catalog's rows in that
85
+ order; nothing hides an extension over it, and a manifest without one is
86
+ worldwide and undated. The build index carries `relevance` for the catalog
87
+ and for later use; the panel does not rank the app's own extensions by it.
88
+
89
+ ```json
90
+ "relevance": {
91
+ "countries": ["us"],
92
+ "dates": ["2026-09-19", "2027-09-18"],
93
+ "interests": ["cw"]
94
+ }
95
+ ```
96
+
97
+ **Where.** Four lists, each an allowlist over the operator's own callsign:
98
+ `entities` (DXCC prefixes as the country file names them: `K`, `VE`, `CE`),
99
+ `countries` (ISO alpha-2: `us`, `cl`), `continents` (`AF AS EU NA OC SA AN`)
100
+ and `ituRegions` (`1`, `2`, `3`). A missing or empty list does not gate;
101
+ values within a list are ORed, the lists ANDed; case is the matcher's job.
102
+ These are the geo keys of HaLo's notices, rule for rule.
103
+
104
+ **When.** `dates` lists the UTC days the extension's events start, as
105
+ `YYYY-MM-DD`. Start days, not spans: a reader asking "is this soon" needs no
106
+ more, and the extension's own code stays the only place that knows when an
107
+ event opens and closes to the minute. An event on a fixed calendar lists the
108
+ occurrences it knows about; one that runs to a weekly rule lists none,
109
+ because a list that must be right every week is a list that goes stale. An
110
+ absent or empty `dates` says nothing about timing — never "never".
111
+
112
+ **For whom.** `interests` draws on a closed vocabulary: the modes `cw`,
113
+ `phone`, `digital`; the band groups `hf`, `vhf`, `uhf`, `microwave`; the
114
+ styles `satellite`, `portable`, `qrp`. Only for an extension *dedicated* to
115
+ one — a contest that permits CW is not a `cw` extension, while the CWops CWT
116
+ is. Listing what an extension merely allows makes every interest match
117
+ everything, which is the same as listing none. The list is closed because an
118
+ interest nothing else spells the same way matches nobody while looking like
119
+ it works.
120
+
121
+ The packer refuses:
122
+
123
+ - a `relevance` that is not an object, or a key inside it other than those six;
124
+ - a list that is not a list, or an element that is not a non-empty string;
125
+ - a continent outside `AF AS EU NA OC SA AN`, or an ITU region outside `1`–`3`;
126
+ - a country that is not two letters, or an entity prefix that is not letters,
127
+ digits and `/` (with an optional leading `*`);
128
+ - a date that is not `YYYY-MM-DD`, or that names a day which does not exist;
129
+ - an interest outside the vocabulary above.
130
+
131
+ The matcher reads a key it cannot parse as "does not gate", so each of the
132
+ shape errors would otherwise ship the bundle as worldwide.
133
+
134
+ ## Using the host's libraries
135
+
136
+ The host carries a set of libraries and hands every extension the same
137
+ instances, so a bundle does not have to ship its own copies:
138
+
139
+ ```
140
+ @ham2k/lib-callsigns @ham2k/lib-qson-adif
141
+ @ham2k/lib-country-files @ham2k/lib-qson-cabrillo
142
+ @ham2k/lib-cqmag-data @ham2k/lib-qson-tools
143
+ @ham2k/lib-dxcc-data i18next
144
+ @ham2k/lib-format-tools liquidjs
145
+ @ham2k/lib-geo-tools
146
+ @ham2k/lib-operation-data
147
+ ```
148
+
149
+ It matters more than it sounds. The same extension, built both ways:
150
+
151
+ | | bundle | packaged |
152
+ |---|---|---|
153
+ | dependencies inlined | 1430 KB | 218 KB |
154
+ | using the host's | 22 KB | **7 KB** |
155
+
156
+ **Declare what you need.** The host carries whatever version *that build*
157
+ shipped with, and you have no say in which app your bundle lands in. So the
158
+ manifest states the ranges you work with:
159
+
160
+ ```json
161
+ "sharedDependencies": {
162
+ "@ham2k/lib-callsigns": "^1.0.0",
163
+ "liquidjs": "^10.0.0"
164
+ }
165
+ ```
166
+
167
+ The host checks those before loading, and refuses a bundle it cannot satisfy —
168
+ which is a message naming the library and both versions, instead of a call
169
+ failing somewhere deep in a hook against a major you never tested.
170
+
171
+ **Declare the version you actually build against, not the oldest that
172
+ compiles.** That check compares versions, and cannot see which *functions* a
173
+ version has. A library that gained an export — `qsonToCabrillo` arrived in
174
+ `@ham2k/lib-qson-cabrillo` 1.2.0, whose earlier releases only read Cabrillo —
175
+ satisfies a `^1.0.0` declaration on a host too old to have it, so the bundle
176
+ installs, loads, and throws the first time an operator asks for the feature
177
+ that needs it. The build's own message suggests the version installed beside
178
+ you for exactly this reason; take it rather than rounding down.
179
+
180
+ The packer refuses a bundle that reaches for a shared module **without**
181
+ declaring it, and one that declares a package the host doesn't carry. Anything
182
+ outside that list you bundle yourself, as normal.
183
+
184
+ Which versions a particular build carries are in its
185
+ `assets/extensions/index.json`, under `sharedModules`.
186
+
187
+ Emitting the lookups is the build preset's job — see **Building** below.
188
+
189
+ ## Building
190
+
191
+ ```sh
192
+ npm install @ham2k/extension-sdk
193
+ npm install --save-dev @ham2k/extension-tools esbuild
194
+ ```
195
+
196
+ `npx -p @ham2k/extension-tools h2kext-init <yourcallsign>-<name>` writes a working one of everything
197
+ below — manifest, source, build script — so the rest of this section is what to
198
+ change rather than what to type.
199
+
200
+ ```js
201
+ import { build } from 'esbuild'
202
+ import { buildExtension } from '@ham2k/extension-tools'
203
+
204
+ await buildExtension(build, { dir: import.meta.dirname })
205
+ ```
206
+
207
+ That reads `manifest.json`, compiles `src/index.ts` into `build/index.js`, and
208
+ copies the manifest alongside it, ready for the packer. The esbuild settings it
209
+ applies are requirements of the runtime rather than preferences: one IIFE
210
+ because the sandbox evaluates a script and has no module loader, `neutral`
211
+ because there is no Node and no DOM, and `es2020` because that is what
212
+ QuickJS-NG speaks.
213
+
214
+ esbuild is yours to bring — the preset takes your `build` function rather than
215
+ importing its own, so there is only ever one copy of it.
216
+
217
+ Everything `sharedDependencies` names becomes a lookup on the host's instance.
218
+ Anything else you use is bundled, as normal.
219
+
220
+ **Using one of the host's libraries without declaring it fails the build**,
221
+ naming the library and the two ways out. That is deliberate: the alternative is
222
+ a bundle that silently carries its own copy — thirty times the size, with its
223
+ own module state — and nothing anywhere that says so. If the second copy is
224
+ what you want, which is how you use a version the host does not carry, say so:
225
+
226
+ ```js
227
+ await buildExtension(build, { dir: import.meta.dirname, inline: ['liquidjs'] })
228
+ ```
229
+
230
+ `extensions/samples/hello-world` is the shortest complete example. Declaring
231
+ what it uses rather than carrying it took the packaged bundle from 185 KB to
232
+ **4 KB**.
233
+
234
+ The preset also settles a trap every author would otherwise hit: the SDK
235
+ reaches `liquidjs`, whose `main`/`module` are its **Node** builds, and
236
+ `platform: 'neutral'` ignores the `browser` field that would pick the right
237
+ one, so a hand-written esbuild config fails on unresolvable Node built-ins
238
+ before it compiles anything — even for an extension that never renders a
239
+ template.
240
+
241
+ ## Packaging
242
+
243
+ ```sh
244
+ node extensions/tools/h2kext-pack.mjs <directory> [-o out.h2kext]
245
+ ```
246
+
247
+ The directory holds your built `index.js`, your `manifest.json`, and optionally
248
+ `assets/`. The packer **packages; it does not compile** — building is yours,
249
+ which is what lets this work outside this repo.
250
+
251
+ It reports every problem at once rather than one per run:
252
+
253
+ ```
254
+ h2kext-pack: ./build is not ready to package:
255
+ manifest.json: missing required field 'name'
256
+ manifest.json: key 'my-clock' must start with your callsign — 'my' is not one (e.g. ki2d-my-clock)
257
+ manifest.json: missing 'api' — declare the extension API this was built against (currently 1)
258
+ ```
259
+
260
+ `--force-name` waives the naming convention below. It exists for Ham2K's own
261
+ packaging, which builds the extensions that ship inside the app under bare keys
262
+ and the reserved prefix; it does not waive what the runtime needs of a key.
263
+
264
+ `extensions/tools/h2kext.mjs` holds the format and its rules, and
265
+ `h2kext-build.mjs` the build preset. Both are free of any dependency on the
266
+ rest of this repo, and ship together as **`@ham2k/extension-tools`** — the
267
+ toolchain half, separate from the **`@ham2k/extension-sdk`** an extension
268
+ imports and bundles.
269
+
270
+ Two things about the SDK package are load-bearing rather than incidental, and
271
+ `extensions/sdk/build.mjs` asserts both:
272
+
273
+ - It ships as **one file per module**, not one bundle. Rolled into a single
274
+ file, your bundler can no longer tell which parts your extension reaches, and
275
+ Hello World comes out five times larger carrying an ADIF importer it cannot
276
+ call.
277
+ - Its JavaScript keeps the shared libraries as **bare specifiers**. The build
278
+ preset can only rewrite an import it can see; resolved inside the SDK, they
279
+ would be gone before it ran, and every extension would carry private copies
280
+ again.
281
+
282
+ `kernel.ts` lives in the SDK's source directory and is never published. It is
283
+ the host — it defines the runtime and holds the one instance of every shared
284
+ library that extensions look up. This repo's own
285
+ build uses the same plugin, which is what keeps the path you take from being
286
+ the untested one.
287
+
288
+ ## Keys
289
+
290
+ An extension key is **`<yourcallsign>-<name>`**:
291
+
292
+ ```
293
+ ki2d-my-clock w7abc-notes 2e0abc-contest-helper
294
+ ```
295
+
296
+ Lowercase, letters digits and hyphens. The callsign is a namespace every author
297
+ of this software already holds — globally unique, free, and already meaningful
298
+ to everyone here — so two people who never met cannot ship the same key. The
299
+ packer refuses anything else.
300
+
301
+ `ham2k-` is reserved for Ham2K's own extensions, and refused to everyone else.
302
+ There is no signing yet, so anyone could claim it, and the key is what the app
303
+ shows when it says where a hook came from.
304
+
305
+ The extensions built into the app use bare keys (`pota`, `sota`, …). Those are
306
+ reserved too: hook identity has to stay unambiguous, and build-secret
307
+ visibility is by key prefix, so an installed extension calling itself `sota`
308
+ would be asking to read `SOTA_*`. The app refuses any collision at install —
309
+ the packer cannot, because a standalone tool has no way to know what the app it
310
+ will be installed into contains.
311
+
312
+ ## What an installed extension does not get
313
+
314
+ **Build secrets.** `host.secret()` returns null for an installed extension
315
+ whatever key it claims, exactly as it does for a dev-served one: visibility
316
+ follows where the code came from, not what it calls itself. A feature that
317
+ needs one degrades the same way it does in a build without that value
318
+ configured.
319
+
320
+ The sandbox is otherwise the same one the built-in extensions run in — no
321
+ filesystem, no network beyond the `domains` the manifest declares and the user
322
+ approved, no timers.
323
+
324
+ ## Installing one
325
+
326
+ **Settings → Extensions → Install from file…**, pick the `.h2kext`, and agree
327
+ to what it asks for. The runtime restarts with it loaded, and the extension
328
+ appears in its own category alongside the app's own, marked *Installed* and
329
+ with a way to remove it again.
330
+
331
+ What the operator is shown before anything is written is the whole point of
332
+ the format being a zip: the file is opened and read, and only then unpacked.
333
+
334
+ Installing a bundle whose key is already installed **updates it in place**.
335
+ What it stored survives — an update is not a reinstall, and re-entering
336
+ credentials every time is how people learn not to update.
337
+
338
+ **Every install asks, including an update that wants nothing new.** Comparing
339
+ capabilities and staying quiet when they had not grown would let a version
340
+ through on the strength of its key alone, and nothing about the *code* behind
341
+ that key is compared — a bundle can change completely and still ask for the
342
+ same things. That may be worth revisiting once a bundle can be shown to come
343
+ from whoever it claims; it is not worth it while a key is all there is.
344
+
345
+ Removing an extension removes what it stored with it.
346
+
347
+ ### Installing from a link
348
+
349
+ A web page can hand the app a bundle to install:
350
+
351
+ ```
352
+ com.ham2k.logger:///install_extension?url=https://example.org/my-ext.h2kext
353
+ ```
354
+
355
+ Every edition of Ham2K Logger answers that scheme, so on a device with more
356
+ than one installed the platform decides which one opens; the catalog keeps a
357
+ plain download link beside its install button for that reason. The
358
+ edition-specific `com.ham2k.logger.dev` / `.next` / `.prod` schemes exist
359
+ but a link should not need them. The URL must be `https`, redirects are followed by hand and refused the
360
+ moment one leaves `https`, and the consent screen names the host the bytes
361
+ came from. Nothing is written until the operator agrees.
362
+
363
+ ### Pre-loaded extensions
364
+
365
+ The app ships eight of the catalog's own extensions already packaged, under
366
+ `app/assets/preloaded-extensions/`, and installs them into the ordinary store
367
+ on first run: `ham2k-pota`, `ham2k-sota`, `ham2k-wwff`, and the five lookups.
368
+ Afterwards they are ordinary installed extensions — the same rows, the same
369
+ update check, the same Update and uninstall — so an operator starts with a
370
+ working app and the catalog can move it forward from there.
371
+
372
+ They are installed once, not every launch. A record of the highest version
373
+ ever pre-loaded per key is what makes that true: an app update carrying a
374
+ newer bundle installs it, a catalog release already ahead of it is left
375
+ alone, and a key the operator uninstalled stays gone.
376
+
377
+ Each one that renames a built-in — `ham2k-pota` is the built-in `pota` under
378
+ a name the catalog can serve — carries the operator's settings, panel values
379
+ and account credentials across on its first install. Copied, not moved: the
380
+ built-in's own state stays where it is.
381
+
382
+ One rule bends for these, and only for them:
383
+
384
+ - **Built-in collisions.** An installed bundle may not claim a key the app
385
+ ships — judged against what the app is currently OFFERING, which under the
386
+ catalog experiment is the core extensions and nothing else. `ham2k-lookup`
387
+ is both a built-in and a pre-load, and could otherwise never install.
388
+
389
+ **Build secrets are not among them, and used to be.** A pre-load holding a
390
+ `ham2k-` key could once read the values its name granted. Nothing needs that
391
+ now: every value it carried — SOTA's OAuth client id, WWFF's API key — ships
392
+ as a constant inside the extension that uses it, because these bundles are
393
+ served to anyone who asks and a `.h2kext` is a zip. An installed extension
394
+ sees no build secret, whatever key it claims and wherever it came from.
395
+
396
+ ### Installing from the catalog
397
+
398
+ **Settings → Extensions → Catalog** lists what [catalog.ham2k.net](https://catalog.ham2k.net)
399
+ publishes on its `stable` channel, in the catalog's own order for the
400
+ operator's callsign (`relevance`, above). Install and Update go through the same consent screen as a
401
+ file, over bytes that must hash to the sha256 the listing gives — a mismatch
402
+ is refused before the zip is opened. A bundle that arrived this way is
403
+ recorded as such (`extensionInstalledFrom`: host, sha256, version), and that
404
+ record is what lets it carry a `ham2k-` key: the rule that refuses the prefix
405
+ to a file stands, waived on every read only for a key whose record names
406
+ `catalog.ham2k.net`, and cleared by an uninstall or by a file install of the
407
+ same key. A key the app ships stays refused whatever the catalog says. The
408
+ catalog's own bundle links (`…/api/v1/extensions/<key>/versions/<version>/bundle`)
409
+ take this verified path when they arrive as a deep link; every other host
410
+ takes the plain one above.
411
+
412
+ At most once every six hours — on launch and on resume — the app asks the
413
+ catalog whether anything installed has a newer release, and says so in the
414
+ status bar and on the row; the notice comes down with the last update taken.
415
+ It never installs unasked. A release the catalog has revoked is put on record
416
+ (`extensionRevoked`: version and note) and stops loading on the spot,
417
+ whatever the operator's own switch says — the row shows "Revoked" with the
418
+ note and no switch, and the record dies with an install of another version
419
+ or an uninstall.
420
+
421
+ ## What is checked at install
422
+
423
+ Beyond what the packer already refused:
424
+
425
+ - the key is not one the app ships, and does not begin `ham2k-`;
426
+ - the `api` version is one this build speaks;
427
+ - every `sharedDependencies` range is satisfied by what this build carries;
428
+ - `relevance`, if present, passes the packer's rules — the shape the matcher
429
+ reads and its vocabulary — applied again by `ExtensionRelevance.validate`;
430
+ - entry names are sanitised on extract, so `../` in one writes nothing.
431
+
432
+ Each has its own message. A bundle that cannot be installed says why.
433
+
434
+ ## Native only, for now
435
+
436
+ Installed bundles are files on disk. On web the app runs from its own assets
437
+ and, for authors, the dev server — [development.md](https://github.com/ham2k/halo/blob/main/docs/extensions/development.md). So on
438
+ web there is nothing to install into: no install from a file, no install or
439
+ update from the catalog, and none of the pre-loaded extensions above.
440
+
441
+ "For now" is a deferral, not a fact about the platform. Almost everything
442
+ here is already platform-neutral — the zip walk, the manifest rules, the
443
+ consent screen, the hash check — and what is left is a persistence seam of
444
+ about six operations. `HALO-583` is the card that picks it up, and says what
445
+ has to be decided first.