@ham2k/extension-tools 1.12.0 → 1.13.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.
package/README.md CHANGED
@@ -60,6 +60,12 @@ size. Using one **without** declaring it fails the build rather than quietly
60
60
  shipping a private copy; pass `inline: ['liquidjs']` if that copy is what you
61
61
  want, which is how you use a version the host does not carry.
62
62
 
63
+ It then activates the built bundle against a stand-in host, and fails the build
64
+ if the bundle registers a settings panel that your manifest's `hooks` does not
65
+ list: the app does not show an undeclared panel. If activation needs more than
66
+ the stand-in provides, or waits on a timer or the host, the build succeeds and
67
+ prints a warning that this check did not run.
68
+
63
69
  ## Packaging
64
70
 
65
71
  ```sh
package/h2kext-build.mjs CHANGED
@@ -23,6 +23,7 @@ import { readFileSync } from "node:fs"
23
23
  import { copyFile, mkdir, readFile } from "node:fs/promises"
24
24
  import { createRequire } from "node:module"
25
25
  import { dirname, join, resolve } from "node:path"
26
+ import vm from "node:vm"
26
27
 
27
28
  import { H2KEXT_API_VERSION, MANIFEST_ENTRY, TIMERS_API_VERSION, sharedModulesFor } from "./h2kext.mjs"
28
29
 
@@ -345,6 +346,8 @@ export async function buildExtension(esbuild, { dir, entry = "src/index.ts", out
345
346
  }),
346
347
  )
347
348
 
349
+ await checkSettingsPanelDeclared({ bundle: join(out, "index.js"), manifest, dir })
350
+
348
351
  // The packer reads both from one directory, so the manifest the bundle was
349
352
  // built against is the manifest that ships with it. Copied rather than left
350
353
  // for the author to remember: a stale manifest beside a fresh bundle is a
@@ -353,3 +356,102 @@ export async function buildExtension(esbuild, { dir, entry = "src/index.ts", out
353
356
 
354
357
  return { outDir: out, manifest }
355
358
  }
359
+
360
+ /// Fails the build when the bundle registers a `settingsPanel` hook its
361
+ /// manifest's `hooks` does not list.
362
+ ///
363
+ /// The app shows an installed extension's settings panel only when its
364
+ /// manifest declares the hook (docs/extensions/settings.md), so the panel
365
+ /// would build, pack, publish and install cleanly and then never appear —
366
+ /// with nothing but a line in the app's console to say why.
367
+ ///
368
+ /// Asked of the code rather than the source: the bundle is evaluated against
369
+ /// a recording stand-in for the kernel and activated, so a panel registered
370
+ /// from a loop or behind an await counts the same as a literal one. The
371
+ /// libraries the host carries come from the author's own node_modules.
372
+ ///
373
+ /// An extension whose activation needs more than the stand-in answers — or
374
+ /// does not finish within [ACTIVATION_DEADLINE_MS] — is not failed for it,
375
+ /// since that would be the check's shortcoming, not the author's, but it is
376
+ /// told the check did not run.
377
+ const ACTIVATION_DEADLINE_MS = 2000
378
+
379
+ async function checkSettingsPanelDeclared({ bundle, manifest, dir }) {
380
+ const require = createRequire(resolve(dir, "noop.js"))
381
+ const sharedModules = new Proxy(
382
+ {},
383
+ {
384
+ get(_, pkg) {
385
+ if (typeof pkg !== "string") return undefined
386
+ try {
387
+ return require(pkg)
388
+ } catch {
389
+ // Left undefined, as on a host without it: a bundle that touches it
390
+ // while activating fails there, and the warning below says so.
391
+ return undefined
392
+ }
393
+ },
394
+ },
395
+ )
396
+ const hostCall = async (method) => (method === "getSettings" ? {} : method.startsWith("dbLookup") ? [] : null)
397
+ const definitions = []
398
+ const sandbox = {
399
+ console: { log() {}, info() {}, warn() {}, error() {}, debug() {} },
400
+ sendMessage: () => {},
401
+ __polo: {
402
+ defineExtension: (definition) => definitions.push(definition),
403
+ hostCall,
404
+ log: () => {},
405
+ invokeLocal: async () => [],
406
+ invokeLocalSequential: async () => [],
407
+ updateInterpretation: () => {},
408
+ getAccountKvKey: (extensionKey) => extensionKey,
409
+ registerDynamicForm: () => {},
410
+ unregisterDynamicForm: () => {},
411
+ host: { showForm: async () => null },
412
+ sharedModules,
413
+ sharedVersions: {},
414
+ },
415
+ }
416
+ sandbox.globalThis = sandbox
417
+
418
+ const registered = new Set()
419
+ let deadline
420
+ try {
421
+ vm.runInContext(await readFile(bundle, "utf8"), vm.createContext(sandbox), { filename: bundle, timeout: ACTIVATION_DEADLINE_MS })
422
+ const activation = (async () => {
423
+ for (const definition of definitions) {
424
+ await definition.onActivation?.({
425
+ hostCall,
426
+ timers: { set: () => 0, clear: () => {} },
427
+ registerHook: (category) => registered.add(category),
428
+ })
429
+ }
430
+ })()
431
+ // The stand-in's timers never fire, so an activation that waits on one
432
+ // never settles — and a pending promise does not keep Node running, so
433
+ // the build would end right here, silently, without copying the manifest.
434
+ await Promise.race([
435
+ activation,
436
+ new Promise((_, reject) => {
437
+ deadline = setTimeout(
438
+ () => reject(new Error(`activation had not finished after ${ACTIVATION_DEADLINE_MS / 1000}s, waiting on a timer or the host`)),
439
+ ACTIVATION_DEADLINE_MS,
440
+ )
441
+ }),
442
+ ])
443
+ } catch (e) {
444
+ console.warn(`warning: could not activate the built bundle to check its "hooks" against what it registers, so that check did not run — ${e?.message ?? e}`)
445
+ return
446
+ } finally {
447
+ clearTimeout(deadline)
448
+ }
449
+
450
+ if (registered.has("settingsPanel") && !(manifest.hooks ?? []).includes("settingsPanel")) {
451
+ throw new BuildError(
452
+ `the extension registers a settings panel, but ${MANIFEST_ENTRY} does not list "settingsPanel" in "hooks" — ` +
453
+ `the app would never show the panel. Add it to "hooks", and say where it goes with "settings" ` +
454
+ `(docs/extensions/settings.md).`,
455
+ )
456
+ }
457
+ }
package/h2kext-pack.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  // The directory holds a built `index.js`, a `manifest.json`, and optionally an
11
11
  // `assets/` folder. Nothing here compiles anything — see h2kext.mjs.
12
12
 
13
- import { pack, PackError, H2KEXT_API_VERSION, RESERVED_PREFIX } from "./h2kext.mjs"
13
+ import { pack, PackError, H2KEXT_API_VERSION, RESERVED_PREFIX, settingsReviewReasons } from "./h2kext.mjs"
14
14
 
15
15
  const args = process.argv.slice(2)
16
16
 
@@ -67,6 +67,11 @@ try {
67
67
  if (result.manifest.webSockets?.length) {
68
68
  console.log(` may hold connections to: ${result.manifest.webSockets.join(", ")}`)
69
69
  }
70
+ // Not a refusal: placements beyond the usual are allowed, once an admin has
71
+ // looked. Said now so a publish that waits in the queue is no surprise.
72
+ for (const reason of settingsReviewReasons(result.manifest)) {
73
+ console.log(` held for review on the catalog: ${reason}`)
74
+ }
70
75
  } catch (e) {
71
76
  if (e instanceof PackError) {
72
77
  console.error(`h2kext-pack: ${dir} is not ready to package:\n${e.message}`)
@@ -528,6 +528,15 @@ export async function main(
528
528
  const result = await publish({ bytes, manifest, channel, catalog, token }, { fetch })
529
529
  say(`published ${result.key} ${result.version} → ${result.channel} (${STATUS_WORDS[result.status] ?? result.status})`)
530
530
  if (result.url) say(result.url)
531
+ // The catalog names what about the settings placement an admin looks at
532
+ // (docs/extensions/settings.md), or which earlier version's identical
533
+ // placement one already approved.
534
+ const review = result.settingsReview
535
+ if (review?.acceptedIn) {
536
+ say(`settings placement as approved in ${review.acceptedIn}`)
537
+ } else {
538
+ for (const reason of review?.reasons ?? []) say(`settings placement for review: ${reason.message ?? reason.code}`)
539
+ }
531
540
 
532
541
  if (notes !== undefined) {
533
542
  try {
package/h2kext.mjs CHANGED
@@ -50,8 +50,12 @@ import yazl from "yazl"
50
50
  /// controls' `hoverSet`, `icon` layers, room for 512 layers and 256 controls,
51
51
  /// knobs' `invertDrag` and scrolls passing through buttons to the knob beneath,
52
52
  /// scenes that scroll natively (`scroll`, with `fixed` layers and controls,
53
- /// and `zoom` strips), `getSpots`' `display`, and `host.suggestQso`.
54
- export const H2KEXT_API_VERSION = 12
53
+ /// and `zoom` strips), `getSpots`' `display`, and `host.suggestQso`; 13 adds
54
+ /// the radio's dial to a scene's live `radio.` values — frequency, passband,
55
+ /// power, PTT, connection, and the words `radio.mode`, `radio.tuner` and
56
+ /// `radio.status` — `when` on layers and controls, and the `radio` panel
57
+ /// trigger.
58
+ export const H2KEXT_API_VERSION = 13
55
59
 
56
60
  /// The first API with `host.webSocket`. A manifest declaring `webSockets`
57
61
  /// under an older one is refused: an older app would install it and fail only
@@ -180,6 +184,7 @@ SHARED_MODULES_BY_API[9] = SHARED_MODULES_BY_API[8]
180
184
  SHARED_MODULES_BY_API[10] = SHARED_MODULES_BY_API[9]
181
185
  SHARED_MODULES_BY_API[11] = SHARED_MODULES_BY_API[10]
182
186
  SHARED_MODULES_BY_API[12] = SHARED_MODULES_BY_API[11]
187
+ SHARED_MODULES_BY_API[13] = SHARED_MODULES_BY_API[12]
183
188
 
184
189
  /// What a scaffolded extension declares on day one: the eight modules the SDK
185
190
  /// barrel itself reaches, so a project that has written no code yet still
@@ -332,6 +337,63 @@ export function validateAdifFieldsScope(manifest) {
332
337
  ]
333
338
  }
334
339
 
340
+ /// The settings sections a manifest's `settings.section` may name without
341
+ /// review — keys of the app's own settings groups. `preferred` is where a
342
+ /// panel usually belongs, by the extension's main purpose; `other` suits one
343
+ /// that tunes something the app already has a section for. The app holds the
344
+ /// same list (`extensionSettingsSections`, app/lib/services/extensions/
345
+ /// settings_placement.dart) and the catalog its own copy; see
346
+ /// docs/extensions/settings.md.
347
+ export const SETTINGS_SECTIONS = {
348
+ preferred: ["activities", "hunting", "contests", "lookups", "spotting", "dashboard"],
349
+ other: ["logging", "radio", "map", "exports"],
350
+ }
351
+
352
+ const SETTINGS_KEYS = ["section", "sidebar", "common"]
353
+
354
+ /// Everything wrong with the SHAPE of a manifest's `settings`, as a list —
355
+ /// empty for a valid one and for an absent one. What it places is not
356
+ /// checked here: a section outside [SETTINGS_SECTIONS], a sidebar entry or
357
+ /// Common Preferences rows are allowed, and reviewed (see
358
+ /// [settingsReviewReasons]).
359
+ export function validateSettingsPlacement(settings) {
360
+ if (settings === undefined) return []
361
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
362
+ return [`'settings' must be an object of ${SETTINGS_KEYS.join(" / ")}`]
363
+ }
364
+ const problems = []
365
+ for (const key of Object.keys(settings)) {
366
+ if (!SETTINGS_KEYS.includes(key)) problems.push(`'settings' has an unknown key '${key}' — only ${SETTINGS_KEYS.join(", ")} are read`)
367
+ }
368
+ if (settings.section !== undefined && (typeof settings.section !== "string" || !/^[a-z][a-z0-9-]*$/.test(settings.section))) {
369
+ problems.push(`'settings.section' must be a settings section's key, such as "${SETTINGS_SECTIONS.preferred[0]}"`)
370
+ }
371
+ for (const key of ["sidebar", "common"]) {
372
+ if (settings[key] !== undefined && typeof settings[key] !== "boolean") problems.push(`'settings.${key}' must be true or false`)
373
+ }
374
+ return problems
375
+ }
376
+
377
+ /// Why the catalog would hold this manifest's settings for an admin's review,
378
+ /// as a list — empty when they go where any extension's may. The app shows
379
+ /// a placement the manifest does not declare as if it were not asked for, so
380
+ /// the manifest is the whole of what is reviewed.
381
+ export function settingsReviewReasons(manifest) {
382
+ const settings = manifest?.settings
383
+ const hasPanel = Array.isArray(manifest?.hooks) && manifest.hooks.includes("settingsPanel")
384
+ const declared = typeof settings === "object" && settings !== null && !Array.isArray(settings) ? settings : {}
385
+ const reasons = []
386
+ const allowed = [...SETTINGS_SECTIONS.preferred, ...SETTINGS_SECTIONS.other]
387
+ if (typeof declared.section === "string") {
388
+ if (!allowed.includes(declared.section)) reasons.push(`'settings.section' is "${declared.section}", which is not one of ${allowed.join(", ")}`)
389
+ } else if (hasPanel) {
390
+ reasons.push(`'hooks' lists 'settingsPanel' but 'settings.section' names no section, so the panel would be a category of its own`)
391
+ }
392
+ if (declared.sidebar === true) reasons.push(`'settings.sidebar' asks for an entry in the Settings sidebar`)
393
+ if (declared.common === true) reasons.push(`'settings.common' asks to add fields to Common Preferences`)
394
+ return reasons
395
+ }
396
+
335
397
  /// Everything wrong with a candidate bundle, as a list — an author fixing one
336
398
  /// problem per run is an author running this five times.
337
399
  export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName = false } = {}) {
@@ -439,6 +501,8 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
439
501
 
440
502
  for (const problem of validateAdifFieldsScope(manifest)) say(problem)
441
503
 
504
+ for (const problem of validateSettingsPlacement(manifest.settings)) say(problem)
505
+
442
506
  return problems
443
507
  }
444
508
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-tools",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Build and package extensions for the Ham2K Logger",
5
5
  "keywords": [
6
6
  "ham2k",