@ham2k/extension-sdk 0.10.0 → 0.11.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.
@@ -83,7 +83,8 @@ function activityExportHook(rules) {
83
83
  exportData: args.exportData,
84
84
  exportTitle: args.exportTitle,
85
85
  mainHandler: rules.key,
86
- includeFieldsFrom: rules.includeFieldsFrom
86
+ includeFieldsFrom: rules.includeFieldsFrom,
87
+ operatorFallback: rules.operatorFallback
87
88
  });
88
89
  return {
89
90
  filename: filenameFor(args.operation, args.qsos, wanted, args.compactFilenames),
@@ -155,7 +156,8 @@ function huntingExportHook(rules) {
155
156
  // key ('pota'), which is what its `adifFields` hook registers under;
156
157
  // the '-hunter' suffix belongs to the export registration alone.
157
158
  mainHandler: rules.key,
158
- includeFieldsFrom: rules.includeFieldsFrom
159
+ includeFieldsFrom: rules.includeFieldsFrom,
160
+ operatorFallback: rules.operatorFallback
159
161
  });
160
162
  return {
161
163
  // The FULL qsos list, matching the option's filename — see
@@ -177,7 +179,8 @@ async function adifForExport({
177
179
  exportData,
178
180
  exportTitle,
179
181
  mainHandler,
180
- includeFieldsFrom
182
+ includeFieldsFrom,
183
+ operatorFallback
181
184
  }) {
182
185
  const { hooks } = await import("@ham2k/extension-sdk");
183
186
  const entries = await hooks.invokeOne("export", "adif", "generateExport", {
@@ -193,7 +196,8 @@ async function adifForExport({
193
196
  exportData,
194
197
  exportTitle,
195
198
  mainHandler,
196
- includeFieldsFrom
199
+ includeFieldsFrom,
200
+ operatorFallback
197
201
  });
198
202
  const content = entries.find((e) => e.ok)?.value?.content;
199
203
  if (!content) {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.10.0
1
+ // @ham2k/extension-sdk 0.11.0
2
2
  /** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface PanelScene {
4
4
  version: 1;
@@ -661,6 +661,7 @@ export interface AdifFieldsHook {
661
661
  fieldsForOneQSO(args: {
662
662
  qso: Record<string, JSONValue>;
663
663
  operation: Record<string, JSONValue>;
664
+ mainHandler?: boolean;
664
665
  }, ctx: HookContext): Promise<{
665
666
  name: string;
666
667
  value: string;
@@ -718,7 +719,9 @@ export interface ExportRequest {
718
719
  includePrivateData?: boolean;
719
720
  mainHandler?: string;
720
721
  includeFieldsFrom?: string[];
722
+ operatorFallback?: OperatorFallback;
721
723
  }
724
+ export type OperatorFallback = "base" | "station";
722
725
  export interface ExportResult {
723
726
  filename: string;
724
727
  mimeType: string;
@@ -1057,6 +1060,24 @@ export interface SyncTransportHook {
1057
1060
  ref?: string | null;
1058
1061
  error?: string;
1059
1062
  }>;
1063
+ uploadStashFile?(args: {
1064
+ file: {
1065
+ filename: string;
1066
+ content: string;
1067
+ mimeType?: string;
1068
+ format?: string;
1069
+ };
1070
+ groupUuid: string;
1071
+ groupTitle?: string;
1072
+ }, ctx: HookContext): Promise<{
1073
+ uploaded?: boolean;
1074
+ error?: string;
1075
+ fatal?: boolean;
1076
+ }>;
1077
+ listStashFiles?(args: {
1078
+ limit?: number;
1079
+ }, ctx: HookContext): Promise<Record<string, JSONValue>>;
1080
+ emailStashLink?(args: Record<string, never>, ctx: HookContext): Promise<Record<string, JSONValue>>;
1060
1081
  }
1061
1082
  export interface AccountField {
1062
1083
  key: string;
@@ -1760,6 +1781,7 @@ export interface ActivityExportRules {
1760
1781
  activationType: string;
1761
1782
  icon?: string;
1762
1783
  includeFieldsFrom?: string[];
1784
+ operatorFallback?: OperatorFallback;
1763
1785
  }
1764
1786
  export declare function activityExportHook(rules: ActivityExportRules): {
1765
1787
  getExportTypes(): Promise<ExportTypeDefinition[]>;
@@ -1774,13 +1796,14 @@ export interface HuntingExportRules {
1774
1796
  icon?: string;
1775
1797
  color?: string;
1776
1798
  includeFieldsFrom?: string[];
1799
+ operatorFallback?: OperatorFallback;
1777
1800
  }
1778
1801
  export declare function huntingExportHook(rules: HuntingExportRules): {
1779
1802
  getExportTypes(): Promise<ExportTypeDefinition[]>;
1780
1803
  suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
1781
1804
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
1782
1805
  };
1783
- export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom, }: {
1806
+ export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom, operatorFallback, }: {
1784
1807
  operation: Record<string, JSONValue>;
1785
1808
  qsos: Record<string, JSONValue>[];
1786
1809
  segments?: OperationSegmentPayload[];
@@ -1791,6 +1814,7 @@ export declare function adifForExport({ operation, qsos, segments, includePrivat
1791
1814
  exportTitle?: string;
1792
1815
  mainHandler: string;
1793
1816
  includeFieldsFrom?: string[];
1817
+ operatorFallback?: OperatorFallback;
1794
1818
  }): Promise<string>;
1795
1819
  export interface ActivityAdifImportRules {
1796
1820
  sig: string;
@@ -310,12 +310,10 @@ packer refuses anything else.
310
310
  There is no signing yet, so anyone could claim it, and the key is what the app
311
311
  shows when it says where a hook came from.
312
312
 
313
- The extensions built into the app use bare keys (`pota`, `sota`, …). Those are
314
- reserved too: hook identity has to stay unambiguous, and build-secret
315
- visibility is by key prefix, so an installed extension calling itself `sota`
316
- would be asking to read `SOTA_*`. The app refuses any collision at install —
317
- the packer cannot, because a standalone tool has no way to know what the app it
318
- will be installed into contains.
313
+ The core extensions built into the app use bare keys (`adif`, `scoring`, …).
314
+ Those are reserved too: hook identity has to stay unambiguous. The app refuses
315
+ any collision at install — the packer cannot, because a standalone tool has no
316
+ way to know what the app it will be installed into contains.
319
317
 
320
318
  ## What an installed extension does not get
321
319
 
@@ -325,14 +323,14 @@ follows where the code came from, not what it calls itself. A feature that
325
323
  needs one degrades the same way it does in a build without that value
326
324
  configured.
327
325
 
328
- The sandbox is otherwise the same one the built-in extensions run in — no
326
+ The sandbox is otherwise the same one the core extensions run in — no
329
327
  filesystem, no network beyond the `domains` and `webSockets` the manifest
330
328
  declares and the user approved, and timers only within their budget
331
329
  (README's "Timers").
332
330
 
333
331
  ## Installing one
334
332
 
335
- **Settings → Extensions → Install from file…**, pick the `.h2kext`, and agree
333
+ **Settings → Extensions & Features → Install from file…**, pick the `.h2kext`, and agree
336
334
  to what it asks for. The runtime restarts with it loaded, and the extension
337
335
  appears in its own category alongside the app's own, marked *Installed* and
338
336
  with a way to remove it again.
@@ -381,108 +379,126 @@ came from. Nothing is written until the operator agrees.
381
379
 
382
380
  ### Pre-loaded extensions
383
381
 
384
- The app ships eight of the catalog's own extensions already packaged, under
385
- `app/assets/preloaded-extensions/`, and installs them into the ordinary store
386
- on first run: `ham2k-pota`, `ham2k-sota`, `ham2k-wwff`, and the five lookups.
387
- Afterwards they are ordinary installed extensions — the same rows, the same
388
- update check, the same Update and uninstall — so an operator starts with a
389
- working app and the catalog can move it forward from there.
382
+ The app builds in only its **core** extensions — the ones that are the app
383
+ itself: `adif`, `scoring`, `lookups`, `templates`, the command sets and
384
+ `ham2k-lofi` sync. Every other extension is a catalog bundle, and the app ships
385
+ some of them already packaged under `app/assets/preloaded-extensions/`,
386
+ installing them into the ordinary store on first run. Afterwards they are
387
+ ordinary installed extensions — the same rows, the same update check, the same
388
+ Update and uninstall — so an operator starts with a working app and the catalog
389
+ can move it forward from there.
390
+
391
+ Which ones ship is staged. Today it is the catalog twin of every extension the
392
+ app used to build in (see [Retired built-ins](#retired-built-ins)), so that an
393
+ install moving off them has every one to hand with no network. Once installs
394
+ still on a build with the built-ins are few enough, it shrinks to the eight a
395
+ new install needs — `ham2k-pota`, `ham2k-sota`, `ham2k-wwff` and the five
396
+ lookups — and the rest are fetched from the catalog when switched on
397
+ ([HALO-743](https://cabo.ham2k.com/halo/c/743)). The app's own tests rely on
398
+ only those eight, the core, and the never-shipped samples, so the shrink
399
+ touches none of them.
400
+
401
+ **They have to be kept current.** A pre-load is frozen into the binary, so
402
+ every release must carry the catalog's current stable release of each one: an
403
+ older one starts every new install behind the catalog until its first update
404
+ check. `node scripts/preloaded-extensions.mjs check` compares them with what
405
+ the catalog serves, byte for byte and at the app's extension API level;
406
+ `node scripts/preloaded-extensions.mjs update` replaces any that are behind
407
+ (`update <key>` adds one). Publishing a new release of a pre-loaded extension
408
+ is therefore also a change to this repository, and the release cut refuses to
409
+ ship pre-loads that fail the check ([releases.md §6.4](https://github.com/ham2k/halo/blob/main/docs/releases.md)).
390
410
 
391
411
  They are installed once, not every launch. A record of the highest version
392
412
  ever pre-loaded per key is what makes that true: an app update carrying a
393
413
  newer bundle installs it, a catalog release already ahead of it is left
394
414
  alone, and a key the operator uninstalled stays gone.
395
415
 
396
- Each one that renames a built-in — `ham2k-pota` is the built-in `pota` under
397
- a name the catalog can serve — carries the operator's settings, panel values
398
- and account credentials across on its first install. Copied, not moved: the
399
- built-in's own state stays where it is.
400
-
401
- One rule bends for these, and only for them:
402
-
403
- - **Built-in collisions.** An installed bundle may not claim a key the app
404
- ships — judged against what the app is currently OFFERING, which once the
405
- upgrade to catalog extensions is taken is the core extensions and nothing
406
- else. `ham2k-lookup`
407
- is both a built-in and a pre-load, and could otherwise never install.
408
-
409
- **Build secrets are not among them, and used to be.** A pre-load holding a
410
- `ham2k-` key could once read the values its name granted. Nothing needs that
411
- now: every value it carried — SOTA's OAuth client id, WWFF's API key — ships
412
- as a constant inside the extension that uses it, because these bundles are
413
- served to anyone who asks and a `.h2kext` is a zip. An installed extension
414
- sees no build secret, whatever key it claims and wherever it came from.
415
-
416
- ### The upgrade to catalog extensions
417
-
418
- A new install — one whose default profile has never had a callsign — starts
419
- on the catalog at its first boot, on the bundles the app ships, with nothing
420
- to offer and nothing to carry; the decision is recorded once
421
- (`seedExtensionsMigratedDefault`). Onboarding still names its activities by
422
- their built-in keys, which `offeredKeyFor` maps to the shipped twin that
423
- actually runs. An install that has been running the app's own extensions is
424
- offered the move at start, on every launch until it is taken. Only a taken upgrade withdraws
425
- anything, and the offer may be deferred. A catalog that cannot be reached
426
- raises no offer and says nothing: it is a launch nobody was watching, and the
427
- offer comes back next time.
428
-
429
- Accepting installs the pre-loads, then fetches the catalog's twin of every
430
- other built-in the operator had switched on — `wca` becomes `ham2k-wca` —
431
- carrying its settings and credentials the same way. Nothing is withdrawn until
432
- that finishes; a download that fails puts the app's own extensions back in use
433
- and the offer returns next launch. Twins that did arrive stay on disk, unused
434
- until an upgrade completes — the built-in each one supersedes is what runs
435
- meanwhile.
436
-
437
- A bundle the engine will not evaluate takes the whole runtime with it, so the
438
- upgrade puts that one aside — the boot names the bundle it died on — and
439
- restarts once more. What boots then is the upgrade minus that extension, named
440
- in the dialog in a sentence of its own. Refused rather than
441
- withdrawn: the panel still lists it, badged, because the row is where an
442
- operator sees that something they installed is doing nothing, and updating it
443
- there is what lifts the refusal. Keyed by the VERSION that failed, so a fixed
444
- release arrives as an ordinary update and clears it. Only an INSTALLED
445
- extension is ever put aside; a built-in that
446
- will not evaluate is a defect in the app, and skipping it would ship an
447
- install quietly missing part of itself.
448
-
449
- A refusal is kept even when putting the bundle aside did not get a runtime
450
- back: it still will not evaluate, and an extension with no built-in behind it
451
- is in the reverted set too, so re-admitting it there would kill the boot that
452
- was about to give the operator everything back. Every dialog, and the notice
453
- that stands in for one, names what stayed out — except a downloaded copy the
454
- revert superseded, whose built-in is running again. Uninstalling clears the
455
- record, so a reinstall of the same bytes gets its chance.
456
-
457
- A refused extension is treated like a revoked one in everything else: it
458
- keeps its switch, and its downloaded data expires on the same clock as any
459
- extension the app is not loading.
460
-
461
- What this does NOT cover is a failure that names no bundle. Only a bundle
462
- that fails while it is being EVALUATED names itself: a kernel that will not
463
- load, or an extension that throws while ACTIVATING, takes the generation down
464
- under a name that is not a key. Those revert the whole upgrade as before, and
465
- it is offered again at the next launch.
466
-
467
- The upgrade restarts the extension runtime, which takes the footer items of
468
- the generation going down with it: a data file's import raises an error rather
469
- than returning into a torn-down runtime, so its progress line is cleared and
470
- the file is left unstamped for the next sweep instead of recorded as fresh
471
- with nothing imported.
472
-
473
- A few built-ins have no twin on purpose — `qp`, which the catalog carries as
474
- one extension per party instead — and the app names them itself
475
- (`kBuiltInsWithoutTwin`). Those are withdrawn without asking the catalog, and
476
- the dialog that reports the upgrade finished names each one the operator had
477
- switched on, since it is where they learn it is gone. Any other twin missing
478
- from the listing fails the upgrade and is asked for again next launch: a
479
- listing lacks a key the same way whether the catalog will never serve it or has
480
- not published it yet, and only the app knows which. A built-in retired from the
481
- catalog on purpose joins that list in the same change.
416
+ An installed bundle may not claim a key the app builds in, and a pre-load is
417
+ no exception: it goes through the same door as any other install.
418
+
419
+ **Build secrets are not among what they get.** Every value a twin once needed —
420
+ SOTA's OAuth client id, WWFF's API key — ships as a constant inside the
421
+ extension that uses it, because these bundles are served to anyone who asks and
422
+ a `.h2kext` is a zip. An installed extension sees no build secret, whatever key
423
+ it claims and wherever it came from.
424
+
425
+ ### Retired built-ins
426
+
427
+ The app used to build in forty-one more extensions — the activities, contests,
428
+ lookups, a spot source and a dashboard panel. Their catalog copies are the same
429
+ extensions under the reserved prefix (`pota` became `ham2k-pota`; `ham2k-lookup`
430
+ kept its name), because the catalog cannot serve a bare key. Everything the app
431
+ stores per extension hangs off the name — its settings group, its panel values,
432
+ its account credentials, the operator's on/off choice — so each twin inherits
433
+ from its built-in. The app keeps a table of the retired keys and whether each
434
+ was on by default (`kRetiredBuiltIns`), since an install may skip any number of
435
+ releases between the last one with its built-ins and this one; it never shrinks.
436
+
437
+ **The move.** An install that ran the built-ins — one whose default profile had
438
+ a callsign before its extension state was first recorded
439
+ (`seedExtensionsMigratedDefault`) — moves onto their twins at its first boot on
440
+ a build without them, unasked: there is nothing left to run instead. The boot's
441
+ catch-up (`catchUpOnCatalogExtensions`) carries each built-in's on/off choice
442
+ onto its twin, over whatever an earlier attempt left there; installs the
443
+ pre-loads, which carry the built-in's settings, panel values and credentials on
444
+ a twin's first install, and its default where the operator never chose; then
445
+ fetches the twin of any other built-in the operator had switched on. It records
446
+ the move done once the pre-load has run, so a boot killed half way carries the
447
+ choices again next time. It needs no network while every twin is pre-loaded.
448
+ The boot starts the extension runtime before settings are wired in, so a
449
+ catch-up that finds none does not count, and wiring them runs it.
450
+ Choices keep being carried after the move, too: onboarding and the
451
+ `ONBOARD_AS` define name activities by their built-in keys, and
452
+ `offeredKeyFor` writes each choice under both names, so a twin that arrives
453
+ later still inherits it.
454
+
455
+ Once moved, the operator sees one notice — not a dialog, since the move was not
456
+ theirs to decide — saying their extensions now come from the catalog, naming
457
+ anything that did not come along, and counting the twins still to arrive from a
458
+ catalog that could not be reached. Any catch-up, the move or not, raises the
459
+ same notice when an extension was put aside or the runtime would not start at
460
+ all, and says so in place of "ready".
461
+
462
+ A pre-load is recorded as offered only once it installed, or when the store
463
+ refuses the bundle itself: a keychain that would not open or a full disk is
464
+ tried again next launch, and meanwhile the adoption fetches the catalog's copy
465
+ if the operator had it on. A twin the pre-load did install is never fetched
466
+ back after the operator uninstalls it. A choice stored under the pre-load's own
467
+ name — `ham2k-lookup`'s, which kept its name — is kept rather than dropped as
468
+ stale.
469
+
470
+ **What does not come along.** A few built-ins have no twin on purpose — `qp`,
471
+ which the catalog carries as one extension per party instead, and `stateparks`,
472
+ one per event — and the app names them itself (`kBuiltInsWithoutTwin`). The
473
+ notice names each one the operator had switched on, since it is where they learn
474
+ it is gone. Any other twin missing from the listing is asked for again next
475
+ launch: a listing lacks a key the same way whether the catalog will never serve
476
+ it or has not published it yet, and only the app knows which. A built-in
477
+ retired from the catalog on purpose joins that list in the same change.
478
+
479
+ **A twin that will not run.** A bundle the engine will not evaluate takes the
480
+ whole runtime with it, so the catch-up puts that one aside — the boot names the
481
+ bundle it died on — and restarts once more, the notice naming it in a sentence
482
+ of its own. Refused rather than withdrawn: the panel still lists it, badged,
483
+ because the row is where an operator sees that something they installed is
484
+ doing nothing, and updating it there is what lifts the refusal. Keyed by the
485
+ VERSION that failed, so a fixed release arrives as an ordinary update and
486
+ clears it. Only an INSTALLED extension is ever put aside; a core one that will
487
+ not evaluate is a defect in the app, and skipping it would ship an install
488
+ quietly missing part of itself. A refused extension is treated like a revoked
489
+ one in everything else: it keeps its switch, and its downloaded data expires on
490
+ the same clock as any extension the app is not loading. A failure that names no
491
+ bundle — a kernel that will not load, an extension that throws while
492
+ ACTIVATING — puts nothing aside, and is reported.
493
+
494
+ A twin fills the same lookup categories its built-in did, under data-file keys
495
+ of its own. Each retired built-in is stamped disabled, so a week on the
496
+ stale-data purge clears the data files it left — leaving alone the rows a live
497
+ extension's data file still fills.
482
498
 
483
499
  ### Installing from the catalog
484
500
 
485
- **Settings → Extensions → Catalog** lists what [catalog.ham2k.net](https://catalog.ham2k.net)
501
+ **Settings → Extensions & Features** lists what [catalog.ham2k.net](https://catalog.ham2k.net)
486
502
  publishes on its `stable` channel, in the catalog's own order for the
487
503
  operator's callsign (`relevance`, above). Install and Update go through the same consent screen as a
488
504
  file, over bytes that must hash to the sha256 the listing gives — a mismatch
@@ -496,6 +512,39 @@ catalog's own bundle links (`…/api/v1/extensions/<key>/versions/<version>/bund
496
512
  take this verified path when they arrive as a deep link; every other host
497
513
  takes the plain one above.
498
514
 
515
+ ### Release tracks
516
+
517
+ A catalog channel — `stable`, `unstable` or `bleeding` — says how finished a
518
+ release is, and the catalog cascades: asking for `unstable` sees unstable and
519
+ stable releases. The panel's listing asks for `stable`. Under developer mode,
520
+ **Developer → Extension Catalog → Catalog Search Tracks** (Only Stable,
521
+ Unstable or better, Bleeding or better) widens it; turning developer mode off
522
+ puts the listing back on stable without touching anything installed. Only
523
+ the panel's listing reads it: fetching the catalog twins of retired built-ins
524
+ reads stable, since those install with no consent screen, and restoring a
525
+ missing extension reads the track it followed.
526
+
527
+ The other way to an unfinished release is a link: the catalog's install
528
+ button, or an address pasted into the panel's search field — a catalog
529
+ bundle URL, an extension's page (`/extensions/<key>?channel=unstable`, which
530
+ installs that channel's current release), the `install_extension` deep link
531
+ itself, or an https URL ending in `.h2kext`; anything else is a search. A
532
+ bundle URL is resolved against the listing on `bleeding`, which sees every
533
+ release, and the version's channel is the steadiest one it is approved on. A
534
+ page link records the channel it asked for as the track even when the release
535
+ there turns out to be steadier — the operator chose that track.
536
+
537
+ Whichever way it arrives, the consent screen warns over a release that is not
538
+ stable, and the install records its channel in the provenance record as the
539
+ TRACK the extension follows (`channel`, absent meaning stable). The update
540
+ check asks about each extension on its own track — one request per track in
541
+ use, side by side — and offers only what is newer than what is installed, so an extension installed at `0.1` unstable is updated to `1.0` stable,
542
+ and then to `1.0.1` unstable. The track only widens on an install: a stable
543
+ release landing over an unstable install leaves it on unstable. A pre-load
544
+ counts as the catalog's own here. The row and the details show the track, and
545
+ the details' **Follow stable releases only** puts it back on stable; an
546
+ uninstall or a file install clears it with the rest of the record.
547
+
499
548
  At most once every six hours — on launch and on resume — the app asks the
500
549
  catalog whether anything installed has a newer release, and says so in the
501
550
  status bar and on the row; the notice comes down with the last update taken.
@@ -526,15 +575,72 @@ Beyond what the packer already refused:
526
575
 
527
576
  Each has its own message. A bundle that cannot be installed says why.
528
577
 
529
- ## Native only, for now
530
-
531
- Installed bundles are files on disk. On web the app runs from its own assets
532
- and, for authors, the dev server — [development.md](https://github.com/ham2k/halo/blob/main/docs/extensions/development.md). So on
533
- web there is nothing to install into: no install from a file, no install or
534
- update from the catalog, and none of the pre-loaded extensions above.
535
-
536
- "For now" is a deferral, not a fact about the platform. Almost everything
537
- here is already platform-neutral — the zip walk, the manifest rules, the
538
- consent screen, the hash check — and what is left is a persistence seam of
539
- about six operations. `HALO-583` is the card that picks it up, and says what
540
- has to be decided first.
578
+ ## Where installed bundles live
579
+
580
+ Everything above is platform-neutral — the zip walk, the manifest rules, the
581
+ consent screen, the hash check. What differs is only where the bytes are kept
582
+ (`BundleStorage`):
583
+
584
+ - **natively**, one directory per key under the app's data directory, one
585
+ file per bundle entry;
586
+ - **on web**, one IndexedDB record per key (database `halo-extensions`),
587
+ holding every entry's bytes under its own name. IndexedDB rather than the
588
+ Origin Private File System, which would mirror the native layout: every
589
+ browser HaLo runs in supports it the same way, while OPFS writes from the
590
+ page are newer and patchier on Safari.
591
+
592
+ So the web build installs from a file, installs and updates from the catalog,
593
+ pre-loads the shipped bundles and moves off the retired built-ins exactly as
594
+ the native ones do.
595
+
596
+ ### When the store is lost
597
+
598
+ The store can go while the preferences describing it survive: a browser
599
+ clearing IndexedDB alone, a data directory deleted by hand, a store that
600
+ failed mid-session. (When a browser clears a whole site, preferences go with
601
+ it, and the app starts as a new install.) So what the operator installed is
602
+ kept apart from the store that holds it, and put back from there.
603
+
604
+ `extensionsInstalled` records every extension the operator installed
605
+ (`{key: name}`). Every install writes it; only an uninstall — or Forget, below
606
+ — removes it. An install that predates it is seeded from its store once, and
607
+ after that it never follows the store: a store found short is exactly what it
608
+ must not shrink to. Whatever is recorded there and not in the store is
609
+ **missing**, however it went — judged by the keys the store holds, not by the
610
+ extensions the app offers, since a built-in or a dev server can stand in front
611
+ of an installed bundle. A store that cannot be read at all has lost nothing:
612
+ that launch offers nothing, rather than everything back into a failing store.
613
+ Where each came from is its provenance record's answer, and a key with none
614
+ came from a file.
615
+
616
+ The operator's on/off choice for a missing extension is kept too: the boot
617
+ cleanup that prunes settings for keys the app no longer knows treats every
618
+ recorded key as known.
619
+
620
+ Putting them back, after the app starts:
621
+
622
+ - **Shipped bundles** come back at boot from the app's own assets, unasked and
623
+ offline. The pre-load record says they were already offered, which is true
624
+ and beside the point; they are installed anyway, never as a first install,
625
+ so their settings and on/off choice stand. An install that has not taken the
626
+ upgrade gets back only what it had.
627
+ - **Everything else** is offered in a dialog once the boot has settled:
628
+ - extensions from the catalog in use are listed to install, at the release
629
+ it serves now. **Later** keeps every one for the next launch;
630
+ - extensions from a file are listed to install again from their files, and
631
+ ones from a different catalog as not available here. Neither is fetched —
632
+ the bytes would come from a publisher the operator never agreed to;
633
+ - every row has **Forget**, which drops it with everything it stored — the
634
+ only way out for one the catalog no longer serves, which would otherwise
635
+ be offered, and fail, every launch.
636
+ - **Install** installs each on its own: one that is not listed, will not
637
+ download or fails the check costs only itself. The runtime restarts with
638
+ everything that landed; a bundle it will not evaluate is set aside, as the
639
+ upgrade does, and the runtime restarts without it. The result names what
640
+ could not be installed (offered again next launch) and what installed but
641
+ would not run (kept, marked in the panel, where an update is the way back);
642
+ says so when the runtime would not start at all, rather than calling it a
643
+ success; and says the app is still busy — offering them again next launch
644
+ — when another catalog pass holds the store.
645
+ - The offer waits for the boot's catch-up to finish, or for an upgrade that
646
+ every catch-up stepped aside for to let go.
package/docs/hooks.md CHANGED
@@ -162,7 +162,8 @@ describes it whenever it was last worked. It still labels a carried-forward
162
162
  field by where it came from (`Call History` for a typed one, `Call History
163
163
  (guess)` for a guessed one), and a station's whole location travels in one
164
164
  of the two so the merge's grid/city reconciliation cannot split it — see
165
- `extensions/lookups/call-history/src/index.ts`.
165
+ `ham2k-call-history`'s `src/index.ts` in
166
+ [ham2k/extensions](https://github.com/ham2k/extensions).
166
167
 
167
168
  ## ✅ `recentContextLookup` — live-context lookups
168
169
 
@@ -741,12 +742,17 @@ knowing them. Called by the `adif` extension **inside the runtime** via
741
742
 
742
743
  ```ts
743
744
  interface AdifFieldsHook {
744
- fieldsForOneQSO(args: { qso, operation }, ctx): Promise<{name, value}[]>
745
+ fieldsForOneQSO(args: { qso, operation, mainHandler? }, ctx): Promise<{name, value}[]>
745
746
  // One field set PER ADIF RECORD this contact should produce.
746
- fieldCombinationsForOneQSO?(args: { qso, operation }, ctx): Promise<{name, value}[][]>
747
+ fieldCombinationsForOneQSO?(args: { qso, operation, mainHandler? }, ctx): Promise<{name, value}[][]>
747
748
  }
748
749
  ```
749
750
 
751
+ `mainHandler` is true when the file is this extension's own export — app-polo's
752
+ argument of the same name. It is how a program writes what only its own
753
+ submission asks for: SOTA's files carry both stations' gridsquares, which the
754
+ full export leaves to the contact's own privacy rules.
755
+
750
756
  POTA contributes `SIG/SIG_INFO/POTA_REF` (hunted refs on the QSO) and
751
757
  `MY_SIG/MY_SIG_INFO/MY_POTA_REF` (activation refs on the operation).
752
758
 
@@ -819,11 +825,24 @@ would claim no reference at all.
819
825
  **A field name is written once per record, and the first to carry a value
820
826
  wins.** ADIF gives no meaning to a repeated field, so where two hooks answer
821
827
  the same name — the full export asks every program on the contact, and a park
822
- and a summit both answer `MY_SIG` — the later one is dropped, and the contact's own fields
823
- outrank all of them. Hooks are asked main handler first, then each
824
- `includeFieldsFrom` key in the order given, so that order decides — whichever
825
- of the two methods each hook answered. app-polo resolves a collision the same
826
- way ("keep the first one defined"). The **full export**, which asks every
828
+ and a summit both answer `MY_SIG` — the later one is dropped. Hooks are asked
829
+ main handler first, then each `includeFieldsFrom` key in the order given, so
830
+ that order decides — whichever of the two methods each hook answered. The
831
+ contact's own fields come after every hook's: a program knows what its file
832
+ needs in a field better than the contact's generic answer does (the section a
833
+ Field Day station sent, a satellite's downlink band). The exception is what
834
+ identifies the contact — `CALL`, `QSO_DATE`, `TIME_ON`, `QSO_DATE_OFF`,
835
+ `TIME_OFF`, `BAND`, `FREQ`, `MODE`, `SUBMODE`, `STATION_CALLSIGN`,
836
+ `OPERATOR`: a hook answering one of those is dropped, since it would rewrite
837
+ every record it is asked about, the whole-log backup included. A program whose
838
+ uploader needs `OPERATOR` on every record asks for it on the export instead,
839
+ `ExportRequest.operatorFallback` (`operatorFallback` on `activityExportHook`'s
840
+ and `huntingExportHook`'s rules): the core writes `OPERATOR` only where it says
841
+ something `STATION_CALLSIGN` doesn't, and with `'base'` or `'station'` it
842
+ writes the contact's own operator on every record, falling back to the station
843
+ call's base call or the station call itself. This is a
844
+ deliberate divergence from app-polo, whose contact fields come first and whose
845
+ main handler's are appended unchecked, so a name both answer is written twice. The **full export**, which asks every
827
846
  program on the contact, has no such order: hooks answering `fieldCombinationsForOneQSO` are asked as one
828
847
  group and hooks answering `fieldsForOneQSO` as another, so which of two programs
829
848
  keeps a shared `MY_SIG` there is not something either of them chose.
@@ -1397,6 +1416,26 @@ report — as a real CaBo card; the host writes the title, body and kind, the
1397
1416
  transport adds who/where/what-build and owns the account gate.
1398
1417
  Implemented by: `ham2k-lofi`.
1399
1418
 
1419
+ `uploadStashFile` / `listStashFiles` / `emailStashLink`
1420
+ (optional) back the Exports pane's Stash button, which sends export files
1421
+ to a store the operator fetches from any browser (Ham2K's File Stash, at
1422
+ stash.ham2k.net). `uploadStashFile` takes ONE file and the `groupUuid` of
1423
+ the batch it belongs to — the host mints it, since the sandbox has no uuid
1424
+ source, and calls once per file: its call timeout does not stop a hook, so a
1425
+ whole batch in one call would go on uploading unseen after the host had
1426
+ told the operator it failed. It answers `{ uploaded: true }` or `{ error }`,
1427
+ never a throw, with `fatal: true` on any failure but the server refusing
1428
+ that one file — sync off, no account or permission, no answer — so the host
1429
+ stops instead of having every remaining file fail the same way. No answer
1430
+ says the file may have arrived: a body can reach the server after the reply
1431
+ is lost.
1432
+ `listStashFiles` answers the server's `{ stash_files: [...] }`, newest
1433
+ first, which the host reads to warn a free account before a new stash
1434
+ replaces one not yet fetched; `emailStashLink` mails the account's own
1435
+ address a link to its stash. Both answer `{ error }` for a refusal. All
1436
+ three own the same sync gate `submitReport` does.
1437
+ Implemented by: `ham2k-lofi`.
1438
+
1400
1439
  ## ✅ `panel` — panes an extension contributes to a view
1401
1440
 
1402
1441
  A panel is a PANE the operator can put in a customizable view's layout
package/docs/settings.md CHANGED
@@ -521,5 +521,5 @@ standalone dialog — not inline in a `settingsPanel`'s `FormRenderer`.
521
521
 
522
522
  A `settingsPanel` field can reference an account for display/navigation via
523
523
  `fieldType: 'account'` (see above), but the account itself is still fully
524
- owned by its `AccountHook` registration. `extensions/lookups/qrz` continues
524
+ owned by its `AccountHook` registration. `ham2k-qrz` continues
525
525
  to register under `account` — there is no migration to do.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
@@ -71,12 +71,12 @@
71
71
  "build": "node build.mjs"
72
72
  },
73
73
  "//peerDependencies": [
74
- "The libraries the extension host carries. An extension does NOT need these",
74
+ "The libraries the extension host provides. An extension does NOT need these",
75
75
  "installed: the build preset rewrites each one the manifest declares into a",
76
76
  "lookup on the host's single instance, before esbuild ever resolves it, and",
77
- "the published .d.ts carries their types rolled in. They are here, optional,",
77
+ "the published .d.ts has their types rolled in. They are here, optional,",
78
78
  "for the one case that does reach the filesystem \u2014 `inline: [...]`, which",
79
- "ships your own copy because you need a version the host does not carry."
79
+ "ships your own copy because you need a version the host does not provide."
80
80
  ],
81
81
  "peerDependencies": {
82
82
  "@ham2k/lib-callsigns": "*",
@@ -17,8 +17,8 @@
17
17
  // band, one contact per station per band. What this sample leaves out is
18
18
  // everything around them — Cabrillo export, the ADIF exchange fields, the
19
19
  // country-file zone suggestion, and writing a guessed exchange onto the QSO.
20
- // See `docs/hooks.md` §`scoring`, and `contests/cqww` in the app's repository
21
- // for the whole thing.
20
+ // See `docs/hooks.md` §`scoring`, and the catalog's `ham2k-cqww` for the whole
21
+ // thing.
22
22
 
23
23
  import { annotateCallAgainstCountryFile, contestArithmetic, contestScorer, contestSummary, defineExtension } from "@ham2k/extension-sdk"
24
24
  import type { ContestScorer, HookContext, JSONValue, LoggingControlDescriptor, Ref } from "@ham2k/extension-sdk"