@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.
- package/AGENTS.md +139 -0
- package/LICENSE +21 -0
- package/README.md +19 -8
- package/dist/activityExports.js +12 -6
- package/dist/activityScoring.js +2 -0
- package/dist/dxcc.js +15 -2
- package/dist/index.d.ts +71 -4
- package/dist/index.js +16 -14
- package/dist/modes.js +20 -0
- package/dist/refTransforms.js +34 -0
- package/dist/referenceActivity.js +89 -18
- package/dist/scoring.js +11 -4
- package/dist/segments.js +17 -0
- package/dist/templateContext.js +4 -0
- package/docs/distribution.md +445 -0
- package/docs/forms.md +279 -0
- package/docs/hooks.md +1379 -0
- package/docs/settings.md +524 -0
- package/docs/templates.md +206 -0
- package/package.json +22 -3
- package/samples/README.md +33 -0
- package/samples/k2hrc-cqww/build.mjs +6 -0
- package/samples/k2hrc-cqww/manifest.json +23 -0
- package/samples/k2hrc-cqww/src/index.ts +273 -0
- package/samples/k2hrc-hamqth/build.mjs +6 -0
- package/samples/k2hrc-hamqth/manifest.json +24 -0
- package/samples/k2hrc-hamqth/src/index.ts +202 -0
- package/samples/k2hrc-llota/build.mjs +6 -0
- package/samples/k2hrc-llota/manifest.json +25 -0
- package/samples/k2hrc-llota/src/index.ts +172 -0
- package/samples/k2hrc-radio/build.mjs +6 -0
- package/samples/k2hrc-radio/manifest.json +23 -0
- package/samples/k2hrc-radio/src/index.ts +145 -0
package/docs/settings.md
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
# Settings panel schema
|
|
2
|
+
|
|
3
|
+
Extensions can contribute panels to the app's settings screen by registering
|
|
4
|
+
a hook in the `settingsPanel` category. Panels are rendered with the same
|
|
5
|
+
`FormRenderer` used for [ad hoc forms](./forms.md) — a settings panel is a
|
|
6
|
+
`FormDefinition` whose fields' values happen to be persisted, not just
|
|
7
|
+
submitted once.
|
|
8
|
+
|
|
9
|
+
This is a separate, complementary mechanism to the `account` hook category
|
|
10
|
+
(see [Relationship to `AccountHook`](#relationship-to-accounthook) below) —
|
|
11
|
+
`settingsPanel` never defines, stores, or edits account credentials itself.
|
|
12
|
+
|
|
13
|
+
> **Status: ✅ implemented.** Schema, kernel-side dispatch (key-namespacing
|
|
14
|
+
> enforcement at `registerHook` time — including on panel keys a Tier 2
|
|
15
|
+
> hook's `getPanels()` returns at call time, not just the registration key
|
|
16
|
+
> — Tier 1's static `getDefinition`, `getPanels` discovery/flattening),
|
|
17
|
+
> `ExtensionService`, `FormRenderer` support for every field type, and a
|
|
18
|
+
> data-driven panel list in `SettingsView` are all done and reachable from
|
|
19
|
+
> the app. One known gap: `fieldType: 'account'`'s `subkey` (multiple
|
|
20
|
+
> accounts under one extension key) has no real implementation yet —
|
|
21
|
+
> `AccountHook` registrations are one-kvKey-per-registration today, so
|
|
22
|
+
> there's nothing to disambiguate against; `SettingsView` logs a warning
|
|
23
|
+
> rather than silently mismatching if a field ever sets it.
|
|
24
|
+
|
|
25
|
+
There are two tiers, discriminated by a required `kind` tag.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Key namespacing
|
|
30
|
+
|
|
31
|
+
Wherever an extension can define more than one of something under one
|
|
32
|
+
category — multiple settings panels, multiple accounts — the key that
|
|
33
|
+
identifies each one defaults to the extension's own key, but if overridden
|
|
34
|
+
it must equal the extension key or be prefixed `<extensionKey>_`. For
|
|
35
|
+
example, extension `pota` may register panels keyed `pota` (the default) and
|
|
36
|
+
`pota_spots`, but never bare `spots` or camelCase `potaSpots`.
|
|
37
|
+
|
|
38
|
+
This applies to `SimpleSettingsPanel.key`, `DynamicSettingsPanel.key`,
|
|
39
|
+
`SettingsPanelDescriptor.key`, and `AccountHook.kvKey`. It keeps every
|
|
40
|
+
panel/account keyed under its owning extension's namespace, which is how
|
|
41
|
+
storage collisions between extensions are prevented, and how KV access
|
|
42
|
+
control will be scoped once that lands — a write can be checked against the
|
|
43
|
+
calling extension's own key prefix without a separate ownership table.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// Extension key: 'pota'. Two panels — the default and one explicit override.
|
|
47
|
+
async getPanels() {
|
|
48
|
+
return [
|
|
49
|
+
{ key: 'pota', title: 'POTA' }, // default, no override needed
|
|
50
|
+
{ key: 'pota_spots', title: 'POTA Spots' }, // override — extension-key-prefixed
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Tier 1: `SimpleSettingsPanel` — declarative, engine-owned storage
|
|
58
|
+
|
|
59
|
+
The common case: a fixed list of fields, each independently persisted, no
|
|
60
|
+
custom logic. Register the panel once at activation; the engine handles
|
|
61
|
+
reading current values, writing on change, and simple validation. No bridge
|
|
62
|
+
call happens per keystroke.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
export interface SettingsField extends Omit<FormField, 'validate' | 'transform' | 'itemFields'> {
|
|
66
|
+
pattern?: string; // regex; declarative validation instead of a validate() closure
|
|
67
|
+
patternError?: string;
|
|
68
|
+
itemFields?: SettingsField[]; // re-typed, not the inherited FormField[] — see "list" below
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SimpleSettingsPanel {
|
|
72
|
+
kind: 'simple';
|
|
73
|
+
key?: string; // defaults to the extension's key; see "Key namespacing"
|
|
74
|
+
title: string;
|
|
75
|
+
icon?: string;
|
|
76
|
+
order?: number;
|
|
77
|
+
// Defaults true. Set false to keep this panel out of the app's top-level
|
|
78
|
+
// settings navigation (the sidebar list). Independent of dataFilesSection
|
|
79
|
+
// below — this only controls the top-level nav.
|
|
80
|
+
sidebar?: boolean;
|
|
81
|
+
// Defaults false. Set true to also show this panel as a row in the Data
|
|
82
|
+
// Files screen's own "Extensions" section — the ONLY host-recognized
|
|
83
|
+
// alternate surface today, so leave this false for anything not actually
|
|
84
|
+
// about managing data files. Independent of `sidebar`; a panel usually
|
|
85
|
+
// wants both false-then-true, but the two aren't coupled at the type
|
|
86
|
+
// level — inferring this from `sidebar: false` alone would sweep any
|
|
87
|
+
// sidebar-opt-out panel in here.
|
|
88
|
+
dataFilesSection?: boolean;
|
|
89
|
+
fields: (SettingsField | FormActionElement)[];
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Persistence: each field is read/written under a KV path built from the
|
|
94
|
+
panel's key and the field's key — `<panelKey>.<fieldKey>`, where `panelKey`
|
|
95
|
+
is the extension's key unless `key` overrides it (so the common single-panel
|
|
96
|
+
case reads/writes e.g. `myext.apiKey`, never a doubled `myext.myext.apiKey`;
|
|
97
|
+
a second panel keyed `myext_spots` reads/writes `myext_spots.someField`).
|
|
98
|
+
Fields with `fieldType: 'secret'` are read/written through secure storage
|
|
99
|
+
instead of the plain KV store — extensions never see the raw stored value
|
|
100
|
+
beyond what `getDefinition`-equivalent state exposes back to them.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
api.registerHook('settingsPanel', {
|
|
104
|
+
hook: {
|
|
105
|
+
kind: 'simple',
|
|
106
|
+
title: "My Service",
|
|
107
|
+
icon: "cloud",
|
|
108
|
+
fields: [
|
|
109
|
+
{ type: "field", fieldType: "text", key: "endpoint", label: "Webhook URL" },
|
|
110
|
+
{ type: "action", key: "clear", label: "Clear Cache", method: "clearCache" }
|
|
111
|
+
],
|
|
112
|
+
// Not part of the SimpleSettingsPanel type — an extra property the
|
|
113
|
+
// 'clear' action's `method` resolves against at dispatch time. See
|
|
114
|
+
// "The action element" below.
|
|
115
|
+
async clearCache(state, ctx) {
|
|
116
|
+
await purgeLocalCache();
|
|
117
|
+
return "Cache cleared";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Tier 2: `DynamicSettingsPanel` — computed fields, custom logic
|
|
124
|
+
|
|
125
|
+
For panels whose fields, values, or validation depend on runtime state the
|
|
126
|
+
engine can't compute on its own (e.g. options fetched from an API, fields
|
|
127
|
+
that appear/disappear based on another field's value). Mirrors
|
|
128
|
+
[`FormHook`](./forms.md), plus panel discovery metadata and a
|
|
129
|
+
persistence-on-commit hook.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
export interface SettingsPanelDescriptor {
|
|
133
|
+
key: string; // extension key, or <extensionKey>_-prefixed; see "Key namespacing"
|
|
134
|
+
title: string;
|
|
135
|
+
icon?: string;
|
|
136
|
+
order?: number;
|
|
137
|
+
sidebar?: boolean; // see SimpleSettingsPanel.sidebar above; defaults true
|
|
138
|
+
dataFilesSection?: boolean; // see SimpleSettingsPanel.dataFilesSection above; defaults false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface DynamicSettingsPanel {
|
|
142
|
+
kind: 'dynamic';
|
|
143
|
+
key?: string; // same rule; only used as the panel key when getPanels is omitted
|
|
144
|
+
getPanels?(args: Record<string, never>, ctx: HookContext): Promise<SettingsPanelDescriptor[]>;
|
|
145
|
+
getDefinition(args: { panelKey: string }, ctx: HookContext): Promise<FormDefinition>;
|
|
146
|
+
validateField?(args: { panelKey: string; fieldKey: string; value: any; state: Record<string, any> }, ctx: HookContext): Promise<string | null>;
|
|
147
|
+
transformField?(args: { panelKey: string; fieldKey: string; value: any; state: Record<string, any> }, ctx: HookContext): Promise<any>;
|
|
148
|
+
// Fired on every accepted change (post-validate/transform). Unlike
|
|
149
|
+
// FormHook.onChangeField, this isn't patching sibling form state — it's
|
|
150
|
+
// "the field committed, go persist it." The extension is responsible for
|
|
151
|
+
// its own persistence here (e.g. host.setSettings — NOT host.kvSet, which
|
|
152
|
+
// is in-memory only and does not survive a runtime restart), since the
|
|
153
|
+
// engine can't infer storage for computed fields the way it can for
|
|
154
|
+
// Tier 1.
|
|
155
|
+
onChangeField(args: { panelKey: string; fieldKey: string; value: any; state: Record<string, any> }, ctx: HookContext): Promise<void>;
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`state` is always that panel's own values, bare-keyed (never the app's
|
|
160
|
+
`<panelKey>.`-namespaced form), and it holds the value being written — the
|
|
161
|
+
edited field already reads as `value`, not as what it was before. That is true
|
|
162
|
+
whether the edit came from the settings screen or from a `SET` command, so a
|
|
163
|
+
hook may validate across fields without caring which surface it was reached
|
|
164
|
+
from.
|
|
165
|
+
|
|
166
|
+
Core discriminates between the two tiers by the required `kind` tag
|
|
167
|
+
(`'simple' | 'dynamic'`), not by shape — a panel can't accidentally match
|
|
168
|
+
both, and a typo in `kind` is a compile error rather than silent
|
|
169
|
+
misclassification.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## The `list` field type and nested items
|
|
174
|
+
|
|
175
|
+
A settings field can itself be a repeatable list of objects — e.g. a list of
|
|
176
|
+
saved exchange presets. This reuses `FormField`/`SettingsField` recursively
|
|
177
|
+
rather than introducing a separate schema:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
{
|
|
181
|
+
type: "field",
|
|
182
|
+
fieldType: "list",
|
|
183
|
+
key: "presets",
|
|
184
|
+
label: "Saved Presets",
|
|
185
|
+
itemTitle: "${label} — ${exchange}",
|
|
186
|
+
itemFields: [
|
|
187
|
+
{ type: "field", fieldType: "text", key: "label", label: "Name" },
|
|
188
|
+
{ type: "field", fieldType: "text", key: "exchange", label: "Exchange" }
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Each item's shape is described by `itemFields`. In `SettingsField`,
|
|
194
|
+
`itemFields` is typed as `SettingsField[]`, not the plain `FormField[]` a
|
|
195
|
+
Tier 2 form's `itemFields` would allow — so a Tier 1 list's items are
|
|
196
|
+
declarative-only too, same as every other Tier 1 field; a `validate`/
|
|
197
|
+
`transform` closure inside a Tier 1 list item is a type error, not a
|
|
198
|
+
silently-ignored one.
|
|
199
|
+
|
|
200
|
+
The list renders as a single row summarizing the current items as a
|
|
201
|
+
comma-separated list of `itemTitle`s (templated against each item's
|
|
202
|
+
values). Tapping it opens an editor dialog with a row per item plus an
|
|
203
|
+
"Add" row; every mutation commits immediately and "Done" closes the
|
|
204
|
+
dialog. Editing or adding an item opens the same form renderer
|
|
205
|
+
recursively, scoped to `itemFields`, in a nested dialog — there is no
|
|
206
|
+
separate "list item editor" widget, just `FormRenderer` given a smaller
|
|
207
|
+
`FormDefinition`.
|
|
208
|
+
|
|
209
|
+
Set `orderable: true` to allow drag-to-reorder in the editor; omit it for
|
|
210
|
+
lists where order doesn't matter.
|
|
211
|
+
|
|
212
|
+
## The `multiselect` field type
|
|
213
|
+
|
|
214
|
+
Picking several values from a fixed vocabulary — the user cannot enter new
|
|
215
|
+
ones (that's what `list` is for). `value` is a plain string list; `options`
|
|
216
|
+
is the vocabulary, each option optionally flagged `common: true`:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
{
|
|
220
|
+
type: "field",
|
|
221
|
+
fieldType: "multiselect",
|
|
222
|
+
key: "bands",
|
|
223
|
+
label: "Bands",
|
|
224
|
+
icon: "radio",
|
|
225
|
+
value: ["80m", "40m", "20m"],
|
|
226
|
+
options: "$core.bands" // or inline: [{ label: "80m", value: "80m", common: true }, ...]
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Like `list`, it renders as a single row summarizing the selection
|
|
231
|
+
comma-separated; tapping opens an editor dialog with a switch per option.
|
|
232
|
+
Options disclose in up to three tiers: `common: true` ones show upfront,
|
|
233
|
+
unflagged ones behind the first disclosure step, and `uncommon: true`
|
|
234
|
+
ones behind the second — the action-row toggle cycles Show more → Show
|
|
235
|
+
all → Show fewer (or just Show all / Show fewer when no option is flagged
|
|
236
|
+
uncommon). A hidden-tier value that has been selected stays visible for
|
|
237
|
+
the dialog's lifetime. Declare `defaultValue` (a string list) to offer a
|
|
238
|
+
Defaults button restoring that selection; omit it and none is shown. The
|
|
239
|
+
selection always keeps the canonical order of `options` — `orderable` is
|
|
240
|
+
reserved for a future manual-ordering variant and currently ignored.
|
|
241
|
+
|
|
242
|
+
## Reading and writing settings from extensions
|
|
243
|
+
|
|
244
|
+
Extensions access settings through two host calls:
|
|
245
|
+
|
|
246
|
+
- `host.getSettings()` — the full read view: resolved core values (theme,
|
|
247
|
+
locale, font scale, distance units, logging toggles, bands/modes,
|
|
248
|
+
privacy consents, the active profile's `operatorCall`, `operatorName`,
|
|
249
|
+
`operatorEmail`, `operatorClub` and `operatorSection`, `syncEnabled`,
|
|
250
|
+
`developerMode`, `appName`, `enabledExperiments`) plus every extension's own settings group
|
|
251
|
+
under `extensions` (keyed `extension_<key>`). Reading is unrestricted —
|
|
252
|
+
settings are not secrets. Credentials and accounts are deliberately
|
|
253
|
+
absent: they live in the platform keychain via the account hook system,
|
|
254
|
+
never in settings. The `operator*` profile fields beyond the callsign are
|
|
255
|
+
all optional and free-form — an extension must cope with `''`, and must
|
|
256
|
+
not treat `operatorSection` as validated against any section table.
|
|
257
|
+
- `host.setSettings({ key: value, ... })` — writes into the calling
|
|
258
|
+
extension's OWN group only (`extension_<key>`; the namespace is injected
|
|
259
|
+
by the SDK). Core settings and other extensions' groups are read-only.
|
|
260
|
+
|
|
261
|
+
## System lists
|
|
262
|
+
|
|
263
|
+
A field's `options` can name a **system list** instead of inlining the
|
|
264
|
+
choices: a `$`-prefixed identifier, `$<namespace>.<list>`, resolved by the
|
|
265
|
+
app before the definition reaches the renderer. This keeps vocabularies
|
|
266
|
+
owned elsewhere (halo_core's band table, runtime-contributed controls) out
|
|
267
|
+
of settings declarations — both core settings groups and extension
|
|
268
|
+
settingsPanels can reference them.
|
|
269
|
+
|
|
270
|
+
Currently defined (see `app/lib/settings/system_lists.dart`):
|
|
271
|
+
|
|
272
|
+
- `$core.bands` — the extended band vocabulary (halo_core's snapshot of
|
|
273
|
+
lib-operation-data's `EXTENDED_BANDS`); `POPULAR_BANDS` flagged `common`.
|
|
274
|
+
- `$core.modes` — every ADIF mode and submode (snapshot of
|
|
275
|
+
`ADIF_MODES_AND_SUBMODES`); `MAIN_MODES` flagged `common`.
|
|
276
|
+
|
|
277
|
+
The `$extensions.*` namespace is reserved for runtime-derived lists (e.g. a
|
|
278
|
+
future `$extensions.secondaryLoggingControls`). An unknown name resolves to
|
|
279
|
+
no options — logged, and the field renders disabled rather than failing
|
|
280
|
+
the panel.
|
|
281
|
+
|
|
282
|
+
## The `secret` field type
|
|
283
|
+
|
|
284
|
+
Renders as an obscured text field (`fieldType: 'secret'`). For Tier 1
|
|
285
|
+
panels, values are persisted via secure storage rather than the plain KV
|
|
286
|
+
store — see "Persistence" above. Tier 2 panels are responsible for their own
|
|
287
|
+
storage in `onChangeField` and should apply the same care. Not for account
|
|
288
|
+
credentials — those belong on `AccountHook.fields`, not here (see below).
|
|
289
|
+
|
|
290
|
+
## The `account` field type
|
|
291
|
+
|
|
292
|
+
A read-only reference into the separate `account` hook system — renders a
|
|
293
|
+
connection-status row (e.g. "Connected as KI2D" / "Not Connected") with a
|
|
294
|
+
Manage/Connect button that opens that account's own standalone dialog. It
|
|
295
|
+
never defines or stores account data itself:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
{
|
|
299
|
+
type: "field",
|
|
300
|
+
fieldType: "account",
|
|
301
|
+
key: "qrzStatus",
|
|
302
|
+
label: "QRZ.com Account",
|
|
303
|
+
account: { key: "qrz" } // matches the account's AccountHook.kvKey
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
`subkey` is an opaque, extension-defined discriminator, only needed when one
|
|
308
|
+
extension exposes more than one account-like entity under the same `key`
|
|
309
|
+
(e.g. multiple stored profiles) — most accounts don't need it.
|
|
310
|
+
|
|
311
|
+
> **Not implemented yet:** `AccountHook` registrations are one-`kvKey`-per-
|
|
312
|
+
> registration today, so there's no real scenario where two accounts share a
|
|
313
|
+
> `key` to disambiguate between — `SettingsView`'s Manage/Connect handler
|
|
314
|
+
> accepts `subkey` but only matches on `key`, logging a warning if `subkey`
|
|
315
|
+
> is set rather than silently picking the wrong account. Wiring this up for
|
|
316
|
+
> real needs `AccountHook` to grow its own multi-account concept first.
|
|
317
|
+
|
|
318
|
+
## The `action` element
|
|
319
|
+
|
|
320
|
+
A button that invokes a named hook method against the current panel state
|
|
321
|
+
and shows the returned string as a result banner:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
{ type: "action", key: "clear", label: "Clear Cache", method: "clearCache" }
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`method` is resolved dynamically against an extra, undeclared property on
|
|
328
|
+
the registered hook object (for Tier 1) or the hook object backing the panel
|
|
329
|
+
(for Tier 2) — not statically part of `SimpleSettingsPanel`/
|
|
330
|
+
`DynamicSettingsPanel`. The SDK's `Hook` union includes
|
|
331
|
+
`Record<string, unknown>`, so TypeScript permits the extra property on the
|
|
332
|
+
object literal without a cast. This is a general mechanism, not specific to
|
|
333
|
+
credentials — account connection testing lives on `AccountHook.testCredentials`
|
|
334
|
+
instead (see below).
|
|
335
|
+
|
|
336
|
+
## `devMode` fields
|
|
337
|
+
|
|
338
|
+
Any field (or header/divider/action, anything in `fields`/`elements`) can be
|
|
339
|
+
marked `devMode: true` to hide it unless the app's developer mode is on — see
|
|
340
|
+
[forms.md's `devMode` elements](./forms.md#devmode-elements) for the full
|
|
341
|
+
behavior (orange accent when shown, host-driven visibility). Useful for a
|
|
342
|
+
Tier 1 panel that wants to expose a debug field (e.g. a raw API endpoint
|
|
343
|
+
override) without it cluttering the panel for regular users:
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
{ type: "field", fieldType: "text", key: "apiEndpointOverride", label: "API Endpoint Override", devMode: true }
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
## `aliases`: short names for the SET command
|
|
350
|
+
|
|
351
|
+
The `SET`, `SHOW` and `HIDE` commands search every declared setting by key,
|
|
352
|
+
label and description (see `app/lib/settings/settings_catalog.dart`). Matching is by
|
|
353
|
+
subsequence and the ranking is usually enough on its own, but not for a
|
|
354
|
+
setting whose real name buries the word people reach for: `REPORT` matches
|
|
355
|
+
`defaultReport`, `defaultReportCW` and `defaultReportFT8` alike, and none of
|
|
356
|
+
them is obviously the one meant.
|
|
357
|
+
|
|
358
|
+
`aliases` fixes that by giving a field extra names, matched at the same top
|
|
359
|
+
tier as its key:
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
{ type: "field", fieldType: "text", key: "defaultReport", label: "Default Report",
|
|
363
|
+
aliases: ["report", "rst"] }
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
A single string is accepted as well as a list. Aliases are matched
|
|
367
|
+
case-insensitively and ignoring punctuation, like every other term (`zeroBeat`,
|
|
368
|
+
`Zero Beat` and `ZEROBEAT` are the same term), so there is no point declaring
|
|
369
|
+
case or spacing variants of a name that already matches.
|
|
370
|
+
|
|
371
|
+
Aliases are for names an operator would plausibly *reach for* — not a thesaurus.
|
|
372
|
+
Every alias is one more thing that can collide with another setting's real name,
|
|
373
|
+
and a collision costs a disambiguation prompt on a query that used to be exact.
|
|
374
|
+
|
|
375
|
+
## Common Settings and environment gating
|
|
376
|
+
|
|
377
|
+
The app has one settings screen, **Settings**: a collapsible section per
|
|
378
|
+
declared group and panel, led by **Common Settings**, a short list of the
|
|
379
|
+
handful of settings most users ever touch. Which fields land on Common
|
|
380
|
+
Settings —
|
|
381
|
+
and which platforms show a field at all — is declared per-element, not
|
|
382
|
+
maintained as a separate list somewhere else in the app. Any field, link, or
|
|
383
|
+
action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
|
|
384
|
+
|
|
385
|
+
- **`common: true`** — also show this element in the Common Settings
|
|
386
|
+
section, on top of its own group's. Off by default. Not the same thing as
|
|
387
|
+
`FormFieldOption.common`/`uncommon` above — that's a `multiselect`
|
|
388
|
+
option's own disclosure tier (upfront vs. behind "Show more"), a
|
|
389
|
+
different scope (an option inside one field, not the field itself).
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
{ type: "field", fieldType: "select", key: "units", label: "Units", value: "metric", options: [...], common: true }
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
- **`environment: 'ios,android'`** (or `'-web'`, or an array either way) —
|
|
396
|
+
restrict which platform(s) show this element at all. This is a platform
|
|
397
|
+
gate, not a common-vs-everything one: an excluded field disappears from every
|
|
398
|
+
settings surface on that platform, not just Common Settings. Every
|
|
399
|
+
token unprefixed is a whitelist (show ONLY there); every token
|
|
400
|
+
`-`-prefixed is a blacklist (show everywhere EXCEPT there); mixing the two
|
|
401
|
+
forms in one attribute is invalid and treated as if `environment` were
|
|
402
|
+
absent. Tokens: `ios`, `android`, `macos`, `windows`, `linux`, `web` — the
|
|
403
|
+
same vocabulary server notices filter on. Omit `environment` for "every
|
|
404
|
+
platform".
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
// A field that only makes sense with an on-screen keyboard.
|
|
408
|
+
{ type: "field", fieldType: "checkbox", key: "showNumbersRow", label: "Numbers Row", value: false,
|
|
409
|
+
common: true, environment: "ios,android" }
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Both attributes are evaluated fresh every time the panel reloads (same as
|
|
413
|
+
`devMode`), so they can vary with `getDefinition`'s own logic if a Tier 2
|
|
414
|
+
panel wants to compute them — though for most panels a static value is all
|
|
415
|
+
that's needed. `common` only applies to field/link/action elements — a
|
|
416
|
+
header/divider/markdown block can't be individually whitelisted onto Common
|
|
417
|
+
Preferences, since that screen builds its own section headers from each
|
|
418
|
+
group/panel's title rather than reusing the panel's own `FormHeader`
|
|
419
|
+
elements. `environment`, by contrast, applies to every element type,
|
|
420
|
+
headers included — a Tier 2 panel with a platform-specific sub-section can
|
|
421
|
+
gate its own header the same way it gates the fields under it.
|
|
422
|
+
|
|
423
|
+
## Settings targets
|
|
424
|
+
|
|
425
|
+
A third element attribute, **`target`**, opts a field into a target-scoped
|
|
426
|
+
settings modal somewhere else in the app — a curated view built around one
|
|
427
|
+
concern rather than one owning group/panel. The logging view's tune icon is
|
|
428
|
+
the first one: it collects every declared field (core- or
|
|
429
|
+
extension-declared) tagged `target: 'logging'`, wherever it's declared, into
|
|
430
|
+
one dialog.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
{ type: "field", fieldType: "checkbox", key: "showBearing", label: "Show Bearing",
|
|
434
|
+
value: false, target: "logging" }
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
`target` is a single target key, or an array when the same field should
|
|
438
|
+
surface under more than one. A `'<target>-extended'` target (e.g.
|
|
439
|
+
`'logging-extended'`) is the same idea, but starts hidden behind that
|
|
440
|
+
dialog's own "show more settings" disclosure — for a field related to the
|
|
441
|
+
target but niche enough it shouldn't clutter the default view:
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
{ type: "field", fieldType: "checkbox", key: "leftieMode", label: "Leftie Mode",
|
|
445
|
+
value: false, target: "logging-extended" }
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Omitting `target` doesn't hide a field from anywhere it already appears —
|
|
449
|
+
The Settings panel is unaffected either way. It's purely
|
|
450
|
+
additive: an extra place a field can be reached from, on top of wherever
|
|
451
|
+
`common`/`environment` already put it.
|
|
452
|
+
|
|
453
|
+
`type: "field"` (including `fieldType: "account"`) and `type: "action"`
|
|
454
|
+
elements can both set `target` and both appear in a target-scoped modal —
|
|
455
|
+
an action's method dispatches through the same extension host call the
|
|
456
|
+
full settings screens use, and an account field's Manage/Connect button
|
|
457
|
+
opens that account's own standalone dialog, exactly as it would from the
|
|
458
|
+
Settings panel.
|
|
459
|
+
|
|
460
|
+
## Restarting the runtime after a Tier 2 field commits
|
|
461
|
+
|
|
462
|
+
A `kind: 'dynamic'` field can set `restartOnChange: true` when its value is
|
|
463
|
+
only ever consulted at `onActivation` — the common case being a list of URLs
|
|
464
|
+
the extension turns into `dataFile` hook registrations at startup, the way
|
|
465
|
+
`getPanels`/`getDefinition` can be re-queried on demand but `onActivation`
|
|
466
|
+
cannot. Once the field commits, the app calls the same `restartRuntime()`
|
|
467
|
+
already used when the user toggles an extension on/off, so `onActivation`
|
|
468
|
+
runs again and picks up the new value via `host.getSettings()`.
|
|
469
|
+
|
|
470
|
+
Don't reach for this by default — most Tier 2 fields are read fresh by a
|
|
471
|
+
hook that runs on every relevant call (`getDefinition`, `lookup`, etc.), so
|
|
472
|
+
they see a change immediately with no restart needed. Reserve
|
|
473
|
+
`restartOnChange` for the fields that genuinely can't be re-read any other
|
|
474
|
+
way, since a restart is a real interruption, not a free way to "make sure
|
|
475
|
+
everything's fresh."
|
|
476
|
+
|
|
477
|
+
`onActivation` re-running is only half the story — it also needs to read
|
|
478
|
+
that new value, which means `await host.getSettings()` before any
|
|
479
|
+
`registerHook` calls that depend on it. Nothing in the kernel awaits
|
|
480
|
+
`onActivation` itself (it's dispatched fire-and-forget), so those
|
|
481
|
+
`registerHook` calls land whenever that promise happens to resolve, not
|
|
482
|
+
before boot "finishes." In practice this is safe because the app's own
|
|
483
|
+
boot path always makes an unrelated host round trip of its own before
|
|
484
|
+
returning control to any caller — which reliably gives a pending
|
|
485
|
+
`getSettings()` continuation enough time to land first — but there's no
|
|
486
|
+
hard guarantee of that ordering, only an incidental one. Register anything
|
|
487
|
+
that DOESN'T depend on settings (a static builtin list, say) synchronously,
|
|
488
|
+
before the `await`, so at least that part is never subject to this.
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
## Relationship to `AccountHook`
|
|
493
|
+
|
|
494
|
+
`account` and `settingsPanel` are separate, complementary hook categories —
|
|
495
|
+
`settingsPanel` does not supersede `account`. Extensions still define
|
|
496
|
+
account credentials by registering an `account` hook exactly as before:
|
|
497
|
+
|
|
498
|
+
```ts
|
|
499
|
+
export interface AccountField {
|
|
500
|
+
key: string;
|
|
501
|
+
label: string;
|
|
502
|
+
type: 'text' | 'email' | 'secret';
|
|
503
|
+
preface?: string;
|
|
504
|
+
postface?: string;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export interface AccountHook {
|
|
508
|
+
label: string;
|
|
509
|
+
kvKey?: string; // defaults to the extension key; see "Key namespacing"
|
|
510
|
+
fields?: AccountField[];
|
|
511
|
+
oauth2?: OAuth2Config;
|
|
512
|
+
testCredentials(credentials: Record<string, string>, ctx: HookContext): Promise<string>;
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Core aggregates every extension's `account` hook (one hook per account) into
|
|
517
|
+
the account list shown in Settings; each account is stored separately
|
|
518
|
+
(credentials in secure storage, keyed by `kvKey`) and rendered in its own
|
|
519
|
+
standalone dialog — not inline in a `settingsPanel`'s `FormRenderer`.
|
|
520
|
+
|
|
521
|
+
A `settingsPanel` field can reference an account for display/navigation via
|
|
522
|
+
`fieldType: 'account'` (see above), but the account itself is still fully
|
|
523
|
+
owned by its `AccountHook` registration. `extensions/lookups/qrz` continues
|
|
524
|
+
to register under `account` — there is no migration to do.
|