dsh-web-icon-indicator 0.5.0 → 0.5.2
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/CHANGELOG.md +16 -0
- package/README.md +113 -43
- package/README.zh.md +92 -31
- package/icons/plugin.svg +6 -0
- package/lib/client.js +141 -32
- package/lib/index.js +202 -94
- package/lib/types/client/index.d.ts +13 -7
- package/lib/types/index.d.ts +14 -5
- package/package.json +4 -2
- package/test/verify.js +511 -78
package/lib/client.js
CHANGED
|
@@ -2,15 +2,26 @@
|
|
|
2
2
|
* Browser half of dsh-web-icon-indicator (dsh.client bundle, ModuleLoader
|
|
3
3
|
* factory format — hand-written, no build step).
|
|
4
4
|
*
|
|
5
|
-
* Registers one
|
|
6
|
-
*
|
|
7
|
-
* namespace). The card binds the namespace's settings scope, stages edits
|
|
8
|
-
* locally, and writes them through `scope.set` / `scope.unset` (validated and
|
|
9
|
-
* persisted host-side into the profile's settings.yaml).
|
|
5
|
+
* Registers one configuration page and serves BOTH host generations from one
|
|
6
|
+
* bundle:
|
|
10
7
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* - DSH ≥ 0.1.7 (modern): page slot `plugins.row.config` keyed
|
|
9
|
+
* `<package name>#<row id>`, bound to the profile-entry settings namespace
|
|
10
|
+
* through `configForms` (`ctx.configForms.get(entryId)`).
|
|
11
|
+
* - DSH ≤ 0.1.6-alpha.1 (legacy): page slot `settings.plugin.item` keyed by the
|
|
12
|
+
* legacy `web-icon-indicator` namespace, bound through `settingsScope`.
|
|
13
|
+
*
|
|
14
|
+
* Neither settings provider is declared in `inject` — both are read weakly at
|
|
15
|
+
* render time (`resolveScope`) and the slot registration is attempted for both
|
|
16
|
+
* names (`slots.inject` is a no-op for a slot the host never declares). The
|
|
17
|
+
* card body is generation-agnostic: the modern controller and the legacy scope
|
|
18
|
+
* expose the same snapshot + `mutate` / `unset` face. The card stages edits
|
|
19
|
+
* locally and writes them through that scope (validated, revision-fenced, and
|
|
20
|
+
* persisted host-side).
|
|
21
|
+
*
|
|
22
|
+
* The page is inert on deployments that do not compose the host plugin: on the
|
|
23
|
+
* modern line registration is gated on `configForms.whileServed`, and the
|
|
24
|
+
* legacy tab only dispatches namespaces the host actually serves.
|
|
14
25
|
*/
|
|
15
26
|
window.__ModuleLoader__.load({
|
|
16
27
|
id: "dsh-web-icon-indicator",
|
|
@@ -26,9 +37,28 @@ window.__ModuleLoader__.load({
|
|
|
26
37
|
// Official UI primitives (same source the shell's own plugin cards use,
|
|
27
38
|
// e.g. dsh-client-ui-settings-plugins): the standard collapse chevron.
|
|
28
39
|
var primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
40
|
+
// The primitives icon set moved from size-suffixed names (`…Outline14`) to
|
|
41
|
+
// stroke-weight names (`…OutlineRegular` / `…OutlineMedium`); fall back so
|
|
42
|
+
// one bundle keeps working on both host lines.
|
|
43
|
+
var ChevronDownIcon = primitives.IconChevronDownOutlineRegular || primitives.IconChevronDownOutline14;
|
|
29
44
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Settings namespace owned by the host plugin on the MODERN host line
|
|
47
|
+
* (DSH ≥ 0.1.7): the settings service keys every form by the profile entry
|
|
48
|
+
* id, and the bundle patch in `cordis.patch.yml` inserts exactly one row
|
|
49
|
+
* with this id. Keep it in sync with `SETTINGS_NAMESPACE` in lib/index.js
|
|
50
|
+
* and the row id in the patch.
|
|
51
|
+
*/
|
|
52
|
+
var NS = "dsh-web-icon-indicator";
|
|
53
|
+
/**
|
|
54
|
+
* Settings namespace the LEGACY host line (DSH ≤ 0.1.6-alpha.1) serves: the
|
|
55
|
+
* plugin registers it through `settings.installSection`, and this half binds
|
|
56
|
+
* the legacy `settingsScope` to the same string. Also the key the legacy
|
|
57
|
+
* settings tab dispatches in `settings.plugin.item`.
|
|
58
|
+
*/
|
|
59
|
+
var LEGACY_NS = "web-icon-indicator";
|
|
60
|
+
/** `plugins.row.config` key: `<bundle package name>#<row id as the patch declares it>`. */
|
|
61
|
+
var ROW_KEY = "dsh-web-icon-indicator#dsh-web-icon-indicator";
|
|
32
62
|
var LOCALE = "dsh-web-icon-indicator";
|
|
33
63
|
var STATE_NAMES = ["idle", "running", "asking", "done"];
|
|
34
64
|
var EFFECT_NAMES = ["static", "blink", "breath", "rainbow", "heartbeat", "bounce"];
|
|
@@ -82,8 +112,17 @@ window.__ModuleLoader__.load({
|
|
|
82
112
|
bounce: "effectBounce",
|
|
83
113
|
};
|
|
84
114
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Cordis fiber services this browser plugin injects.
|
|
117
|
+
*
|
|
118
|
+
* Deliberately only the two services every host line provides: the settings
|
|
119
|
+
* provider itself is read weakly at render time (`resolveScope`), so one
|
|
120
|
+
* bundle serves both the modern `configForms` line (DSH ≥ 0.1.7) and the
|
|
121
|
+
* legacy `settingsScope` line (≤ 0.1.6-alpha.1). A hard `inject` on either
|
|
122
|
+
* name would leave the fiber un-activated on the other line, which is what
|
|
123
|
+
* broke the 0.5.x bundle on 0.1.7.
|
|
124
|
+
*/
|
|
125
|
+
var inject = ["slots", "locale"];
|
|
87
126
|
|
|
88
127
|
// ---------------------------------------------------------------------------
|
|
89
128
|
// Locale copy
|
|
@@ -642,12 +681,17 @@ window.__ModuleLoader__.load({
|
|
|
642
681
|
return { kind: "set", value: parsed };
|
|
643
682
|
}
|
|
644
683
|
|
|
645
|
-
/**
|
|
684
|
+
/**
|
|
685
|
+
* Subscribe a React component to the scope's snapshot store. `scope` may be
|
|
686
|
+
* null while the host serves no settings provider for our namespace (or
|
|
687
|
+
* before one appears); the hook count stays identical either way, and a
|
|
688
|
+
* null scope yields a null snapshot that renders nothing.
|
|
689
|
+
*/
|
|
646
690
|
function useScopeSnapshot(scope) {
|
|
647
691
|
return useSyncExternalStore(
|
|
648
|
-
function (listener) { return scope.subscribe(listener); },
|
|
649
|
-
function () { return scope.getSnapshot(); },
|
|
650
|
-
function () { return scope.getSnapshot(); }
|
|
692
|
+
function (listener) { return scope ? scope.subscribe(listener) : function () {}; },
|
|
693
|
+
function () { return scope ? scope.getSnapshot() : null; },
|
|
694
|
+
function () { return scope ? scope.getSnapshot() : null; }
|
|
651
695
|
);
|
|
652
696
|
}
|
|
653
697
|
|
|
@@ -1050,11 +1094,18 @@ window.__ModuleLoader__.load({
|
|
|
1050
1094
|
};
|
|
1051
1095
|
|
|
1052
1096
|
var disabled = !writable || saving;
|
|
1097
|
+
// The Plugins page asks a configuration entry twice: `view: 'summary'`
|
|
1098
|
+
// for the row's one-liner and `view: 'page'` for the body of its own
|
|
1099
|
+
// page (the page already heads it with the plugin's title and
|
|
1100
|
+
// description). A host that renders this card standalone passes no
|
|
1101
|
+
// `view` and keeps the collapsible header.
|
|
1102
|
+
if (props.view === "summary") return t("description");
|
|
1103
|
+
var pageView = props.view === "page";
|
|
1053
1104
|
|
|
1054
1105
|
return createElement(
|
|
1055
1106
|
"div",
|
|
1056
1107
|
{ style: styles.card },
|
|
1057
|
-
createElement(
|
|
1108
|
+
pageView ? null : createElement(
|
|
1058
1109
|
"button",
|
|
1059
1110
|
{
|
|
1060
1111
|
type: "button",
|
|
@@ -1073,10 +1124,10 @@ window.__ModuleLoader__.load({
|
|
|
1073
1124
|
createElement(
|
|
1074
1125
|
"span",
|
|
1075
1126
|
{ style: Object.assign({}, styles.chevron, open ? styles.chevronOpen : {}) },
|
|
1076
|
-
createElement(
|
|
1127
|
+
createElement(ChevronDownIcon, {})
|
|
1077
1128
|
)
|
|
1078
1129
|
),
|
|
1079
|
-
open ? createElement(
|
|
1130
|
+
(open || pageView) ? createElement(
|
|
1080
1131
|
"div",
|
|
1081
1132
|
{ style: styles.body },
|
|
1082
1133
|
!writable ? createElement("p", { style: styles.hint }, t("readOnly")) : null,
|
|
@@ -1274,23 +1325,81 @@ window.__ModuleLoader__.load({
|
|
|
1274
1325
|
// ---------------------------------------------------------------------------
|
|
1275
1326
|
// Plugin entry
|
|
1276
1327
|
// ---------------------------------------------------------------------------
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* The settings form this card binds, on whichever host line is running.
|
|
1331
|
+
*
|
|
1332
|
+
* - DSH ≥ 0.1.7 exposes `configForms`, one controller per profile entry id
|
|
1333
|
+
* (snapshot + revision-fenced `mutate` / `unset`); the provider owns the
|
|
1334
|
+
* remote write, so this plugin never declares `remote` itself.
|
|
1335
|
+
* - DSH ≤ 0.1.6-alpha.1 exposes `settingsScope`; binding it to the legacy
|
|
1336
|
+
* namespace yields the same snapshot/write face the card already uses.
|
|
1337
|
+
*
|
|
1338
|
+
* Read weakly on purpose: the two services are mutually exclusive, and the
|
|
1339
|
+
* caller's `inject` list must stay valid on both lines.
|
|
1340
|
+
*/
|
|
1341
|
+
function resolveScope(ctx) {
|
|
1342
|
+
var forms = ctx.get("configForms");
|
|
1343
|
+
if (forms && typeof forms.get === "function") return forms.get(NS);
|
|
1344
|
+
var legacy = ctx.get("settingsScope");
|
|
1345
|
+
if (legacy && typeof legacy.bind === "function") return legacy.bind({ namespace: LEGACY_NS });
|
|
1346
|
+
return null;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1277
1349
|
function apply(ctx) {
|
|
1278
1350
|
var t = ctx.locale.bind(LOCALE);
|
|
1279
1351
|
ctx.effect(function () {
|
|
1280
1352
|
return ctx.locale.register(LOCALE, { zh: zh, en: en });
|
|
1281
|
-
}, "dsh-web-icon-indicator: settings
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1353
|
+
}, "dsh-web-icon-indicator: settings page dictionaries");
|
|
1354
|
+
// The scope is resolved per render (see the inject face below), so the
|
|
1355
|
+
// card follows whichever provider is mounted and renders nothing while
|
|
1356
|
+
// neither is.
|
|
1357
|
+
var pageFace = function () { return { scope: resolveScope(ctx), t: t }; };
|
|
1358
|
+
// Register on BOTH page slots. `ctx.slots.inject(key, …)` is a no-op for a
|
|
1359
|
+
// slot this host never declares, so each line runs exactly the branch it
|
|
1360
|
+
// understands:
|
|
1361
|
+
// - `plugins.row.config` (≥0.1.6-alpha.2): keyed `<package>#<row id>`,
|
|
1362
|
+
// the row's configure control comes from this registration;
|
|
1363
|
+
// - `settings.plugin.item` (≤0.1.6-alpha.1): keyed by the legacy
|
|
1364
|
+
// settings namespace, dispatched by the settings tab per served
|
|
1365
|
+
// namespace.
|
|
1366
|
+
var start = function () {
|
|
1367
|
+
var disposeRow = ctx.slots.inject("plugins.row.config", function () {
|
|
1368
|
+
return ctx.slots.register(
|
|
1369
|
+
{
|
|
1370
|
+
name: "plugins.row.config",
|
|
1371
|
+
key: ROW_KEY,
|
|
1372
|
+
locale: LOCALE,
|
|
1373
|
+
inject: pageFace,
|
|
1374
|
+
},
|
|
1375
|
+
IconConfigCard
|
|
1376
|
+
);
|
|
1377
|
+
});
|
|
1378
|
+
var disposeItem = ctx.slots.inject("settings.plugin.item", function () {
|
|
1379
|
+
return ctx.slots.register(
|
|
1380
|
+
{
|
|
1381
|
+
name: "settings.plugin.item",
|
|
1382
|
+
key: LEGACY_NS,
|
|
1383
|
+
locale: LOCALE,
|
|
1384
|
+
inject: pageFace,
|
|
1385
|
+
},
|
|
1386
|
+
IconConfigCard
|
|
1387
|
+
);
|
|
1388
|
+
});
|
|
1389
|
+
return function () { disposeRow(); disposeItem(); };
|
|
1390
|
+
};
|
|
1391
|
+
// On the modern line, gate registration on the served namespace so an
|
|
1392
|
+
// uncomposed plugin shows no page; `whileServed` owns the returned
|
|
1393
|
+
// disposer, so `start` must hand one back. The legacy line needs no gate:
|
|
1394
|
+
// its tab only dispatches namespaces the host serves.
|
|
1395
|
+
var forms = ctx.get("configForms");
|
|
1396
|
+
if (forms && typeof forms.whileServed === "function") {
|
|
1397
|
+
ctx.effect(function () {
|
|
1398
|
+
return forms.whileServed([NS], start);
|
|
1399
|
+
}, "dsh-web-icon-indicator: settings page");
|
|
1400
|
+
} else {
|
|
1401
|
+
ctx.effect(start, "dsh-web-icon-indicator: settings page");
|
|
1402
|
+
}
|
|
1294
1403
|
}
|
|
1295
1404
|
|
|
1296
1405
|
exports.apply = apply;
|
package/lib/index.js
CHANGED
|
@@ -40,17 +40,17 @@
|
|
|
40
40
|
* Configuration object (all optional):
|
|
41
41
|
* iconsDir Absolute directory holding base.svg. Default `<package>/icons/`.
|
|
42
42
|
* statusPath JSON status endpoint path. Default `/dsh-web-icon-status.json`.
|
|
43
|
-
* Registration-time: composition entry only
|
|
43
|
+
* Registration-time (non-volatile): composition entry only.
|
|
44
44
|
* iconPathPrefix URL prefix for static icon files. Default `/dsh-web-icon-indicator`.
|
|
45
|
-
* Registration-time: composition entry only
|
|
45
|
+
* Registration-time (non-volatile): composition entry only.
|
|
46
46
|
* askingHoldMs Minimum visibility for the asking state (ms). Default 3500.
|
|
47
47
|
* doneHoldMs How long the done state stays before falling back to idle.
|
|
48
48
|
* Default 5000.
|
|
49
49
|
* defaultColor Default icon color — the idle whale's primary color, as
|
|
50
50
|
* `#rgb` / `#rrggbb`. Absent = the idle state's own colors[0]
|
|
51
51
|
* (today's behavior). Give each DSH instance (its own profile /
|
|
52
|
-
* settings
|
|
53
|
-
* apart. A value perceptually too close to another state's
|
|
52
|
+
* user settings layer) a different value to tell their browser
|
|
53
|
+
* tabs apart. A value perceptually too close to another state's
|
|
54
54
|
* color raises a WARNING — surfaced in the settings card, in
|
|
55
55
|
* the host log, and as `warnings` on the status endpoint — but
|
|
56
56
|
* is still honored.
|
|
@@ -67,12 +67,23 @@
|
|
|
67
67
|
* - speed: optional per-state cycle in ms (blink toggle interval too).
|
|
68
68
|
* Default 1200.
|
|
69
69
|
*
|
|
70
|
-
* The whole config surface is
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
70
|
+
* The whole config surface is the plugin's Cordis `Config` schema (exported as
|
|
71
|
+
* both `Config` and `CONFIG_SCHEMA`), and the same schema is registered with
|
|
72
|
+
* the LEGACY settings service when the running host still exposes
|
|
73
|
+
* `installSection` — one bundle serves every published host generation:
|
|
74
|
+
*
|
|
75
|
+
* - DSH ≥ 0.1.7: the settings service projects the schema's `.volatile()`
|
|
76
|
+
* fields into a live form, persisted into the profile patch
|
|
77
|
+
* (`~/.dsh/profiles/<profile>/cordis.patch.yml`); a write mutates the
|
|
78
|
+
* running plugin's config references in place and emits
|
|
79
|
+
* `loader/volatile-update` instead of restarting it.
|
|
80
|
+
* - DSH ≤ 0.1.6-alpha.1: the plugin registers the namespace itself under
|
|
81
|
+
* `web-icon-indicator` and the service drives it through
|
|
82
|
+
* `setSource`/`onChange`, persisted into `~/.dsh/settings.yaml`.
|
|
83
|
+
*
|
|
84
|
+
* `statusPath` / `iconPathPrefix` stay non-volatile in both. When the profile
|
|
85
|
+
* composes no settings service at all, the plugin keeps working on the
|
|
86
|
+
* composition entry + defaults it was mounted with.
|
|
76
87
|
*
|
|
77
88
|
* @module dsh-web-icon-indicator
|
|
78
89
|
*/
|
|
@@ -104,21 +115,54 @@ const EFFECT_NAMES = ["static", "blink", "breath", "rainbow", "heartbeat", "boun
|
|
|
104
115
|
const STATE_NAMES = ["idle", "running", "asking", "done"];
|
|
105
116
|
|
|
106
117
|
/**
|
|
107
|
-
* Settings namespace
|
|
108
|
-
*
|
|
109
|
-
*
|
|
118
|
+
* Settings namespace of this plugin on the MODERN host line (DSH ≥ 0.1.7):
|
|
119
|
+
* the settings namespace IS the profile entry id (`schema(entry)` reads
|
|
120
|
+
* `entry.fiber.runtime.Config` and `describe()` keys every form by
|
|
121
|
+
* `entry.options.id`), and this plugin's bundle patch declares exactly one row:
|
|
122
|
+
* `dsh-web-icon-indicator`. The browser half reads the same namespace through
|
|
123
|
+
* `ctx.configForms.get(...)`, so the two halves must keep this literal in sync
|
|
124
|
+
* (it is also the row id in `cordis.patch.yml`).
|
|
125
|
+
*/
|
|
126
|
+
const SETTINGS_NAMESPACE = "dsh-web-icon-indicator";
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Settings namespace on the LEGACY host line (DSH ≤ 0.1.6-alpha.1), where the
|
|
130
|
+
* consumer-owned namespace is an arbitrary string it passes to
|
|
131
|
+
* `settings.installSection` and the settings document section is named after
|
|
132
|
+
* it. Kept at the historical value so an existing 0.5.x user's
|
|
133
|
+
* `web-icon-indicator:` section keeps working after this upgrade; the browser
|
|
134
|
+
* half binds its legacy scope to the same string.
|
|
135
|
+
*/
|
|
136
|
+
const LEGACY_SETTINGS_NAMESPACE = "web-icon-indicator";
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Mark one field as LIVE when the resolved schemastery supports it.
|
|
110
140
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
141
|
+
* `.volatile()` exists since schemastery 3.18.3 and is what makes the modern
|
|
142
|
+
* settings service render a field at all (`volatileForm()` keeps volatile
|
|
143
|
+
* fields only) and what makes a settings write mutate the running plugin's
|
|
144
|
+
* references + emit `loader/volatile-update` instead of restarting it. On the
|
|
145
|
+
* legacy host line the installed schemastery (3.18.2) has no `.volatile()`,
|
|
146
|
+
* and the legacy settings service owns the whole live-form lifecycle through
|
|
147
|
+
* `installSection`/`setSource`/`onChange`, so the same schema object works
|
|
148
|
+
* unwrapped there.
|
|
114
149
|
*/
|
|
115
|
-
const
|
|
150
|
+
const LIVE = (schema) => (typeof schema.volatile === "function" ? schema.volatile() : schema);
|
|
116
151
|
|
|
117
152
|
/**
|
|
118
|
-
* Schemastery schema mirroring `DEFAULTS`.
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
153
|
+
* Schemastery schema mirroring `DEFAULTS`. Exported as the plugin's `Config`,
|
|
154
|
+
* which is what the Cordis loader validates the composition row against and
|
|
155
|
+
* what the modern host settings service projects into its live form
|
|
156
|
+
* (`volatileForm` keeps only `.volatile()` fields, so the settings page can
|
|
157
|
+
* never advertise a key it cannot honor).
|
|
158
|
+
*
|
|
159
|
+
* `.volatile()` marks a field as LIVE: the loader commits a settings write
|
|
160
|
+
* into the running fiber's references and emits `loader/volatile-update`
|
|
161
|
+
* instead of restarting the plugin (see `apply`). Ordinary (non-volatile)
|
|
162
|
+
* fields — `statusPath` / `iconPathPrefix` — are baked into the route table
|
|
163
|
+
* and the injected script at registration time, so a document change to them
|
|
164
|
+
* could never take effect; keeping them non-volatile removes them from the
|
|
165
|
+
* live form while still validating the composition entry.
|
|
122
166
|
*/
|
|
123
167
|
const STATE_CONFIG_SCHEMA = z.object({
|
|
124
168
|
effect: z.union(EFFECT_NAMES).default("static"),
|
|
@@ -129,24 +173,24 @@ const STATE_CONFIG_SCHEMA = z.object({
|
|
|
129
173
|
});
|
|
130
174
|
|
|
131
175
|
const CONFIG_SCHEMA = z.object({
|
|
132
|
-
askingHoldMs: z.number().min(0).default(DEFAULTS.askingHoldMs),
|
|
133
|
-
doneHoldMs: z.number().min(0).default(DEFAULTS.doneHoldMs),
|
|
176
|
+
askingHoldMs: LIVE(z.number().min(0).default(DEFAULTS.askingHoldMs)),
|
|
177
|
+
doneHoldMs: LIVE(z.number().min(0).default(DEFAULTS.doneHoldMs)),
|
|
134
178
|
// Default icon color (`#rgb` / `#rrggbb`). Deliberately WITHOUT a schema
|
|
135
179
|
// default: an absent value keeps the idle state's own colors[0], exactly as
|
|
136
180
|
// before this key existed. `resolveConfig` folds a valid value into
|
|
137
181
|
// `states.idle.colors[0]` and drops an invalid one with a warning.
|
|
138
|
-
defaultColor: z.string(),
|
|
139
|
-
|
|
140
|
-
// They are baked at registration time (the route table and the injected
|
|
141
|
-
// script's URLs are built in apply()), so a settings-document change to them
|
|
142
|
-
// could never take effect: the browser would poll the new path while the
|
|
143
|
-
// server still served the old one — a dead favicon. Keeping them out of the
|
|
144
|
-
// schema means the settings surface cannot advertise a key it cannot honor;
|
|
145
|
-
// they stay composition-entry only (see the README config table).
|
|
146
|
-
iconsDir: z.string(),
|
|
182
|
+
defaultColor: LIVE(z.string()),
|
|
183
|
+
iconsDir: LIVE(z.string()),
|
|
147
184
|
// dict (not a four-key object): the user layer may override only some
|
|
148
|
-
// states; the plugin merges the resolved value over DEFAULTS anyway.
|
|
149
|
-
|
|
185
|
+
// states; the plugin merges the resolved value over DEFAULTS anyway. The
|
|
186
|
+
// whole dict is one volatile field, so the custom card can stage and write
|
|
187
|
+
// it atomically through `scope.mutate([{ op: 'set', path: ['states'], … }])`.
|
|
188
|
+
states: LIVE(z.dict(STATE_CONFIG_SCHEMA).default(DEFAULTS.states)),
|
|
189
|
+
// Registration-time keys: route table + injected script URLs are built in
|
|
190
|
+
// apply(), so they are composition-entry only (non-volatile ⇒ absent from
|
|
191
|
+
// the live settings form). See the README config table.
|
|
192
|
+
statusPath: z.string().default(DEFAULTS.statusPath),
|
|
193
|
+
iconPathPrefix: z.string().default(DEFAULTS.iconPathPrefix),
|
|
150
194
|
});
|
|
151
195
|
|
|
152
196
|
/** Browser script injected into every served index.html response. */
|
|
@@ -193,6 +237,36 @@ const INJECTED_SCRIPT = `
|
|
|
193
237
|
);
|
|
194
238
|
}
|
|
195
239
|
|
|
240
|
+
// Every DSH shell up to 0.1.6-alpha.2 carried ONE <link rel=icon>. From
|
|
241
|
+
// 0.1.7-alpha.1 the shell ships a THEME-SCOPED PAIR instead:
|
|
242
|
+
// <link rel=icon href=favicon-dark.svg media="(prefers-color-scheme: dark)">
|
|
243
|
+
// <link rel=icon href=favicon.svg media="(prefers-color-scheme: light)">
|
|
244
|
+
// Both links carry rel=icon, so querying just the first one and repainting
|
|
245
|
+
// it was silently useless: the browser resolves the favicon to the LAST
|
|
246
|
+
// connected link whose media condition matches, i.e. the second one — the
|
|
247
|
+
// one this script never touched. The tab then kept the shell's icon forever
|
|
248
|
+
// while the status endpoint changed underneath (the "icon never changes"
|
|
249
|
+
// bug). Normalize to ONE always-matching link first, then keep painting it.
|
|
250
|
+
function normalizeIconLinks() {
|
|
251
|
+
try {
|
|
252
|
+
var all = document.querySelectorAll("[rel~='icon']");
|
|
253
|
+
if (!all || !all.length) return;
|
|
254
|
+
var pick = all[0];
|
|
255
|
+
for (var i = 1; i < all.length; i++) {
|
|
256
|
+
// Prefer the variant the current scheme actually selects, so a
|
|
257
|
+
// dark-mode tab keeps the shell's dark artwork as its resting state.
|
|
258
|
+
var m = all[i].getAttribute("media");
|
|
259
|
+
if (m && window.matchMedia && window.matchMedia(m).matches) pick = all[i];
|
|
260
|
+
}
|
|
261
|
+
for (var j = 0; j < all.length; j++) {
|
|
262
|
+
var node = all[j];
|
|
263
|
+
if (node !== pick && typeof node.remove === "function") { try { node.remove(); } catch (e) {} }
|
|
264
|
+
}
|
|
265
|
+
// Drop the media condition: a link that no longer matches the scheme
|
|
266
|
+
// would be dead weight, and the frame we paint is scheme-agnostic.
|
|
267
|
+
try { pick.removeAttribute("media"); } catch (e) {}
|
|
268
|
+
} catch (e) {}
|
|
269
|
+
}
|
|
196
270
|
// Capture the shell's own favicon ONCE, together with an offline-safe
|
|
197
271
|
// data-URI copy of it. The copy matters: while the DSH host is stopped
|
|
198
272
|
// the original href (a URL served by that same host) is unreachable, so
|
|
@@ -508,6 +582,7 @@ const INJECTED_SCRIPT = `
|
|
|
508
582
|
});
|
|
509
583
|
}
|
|
510
584
|
captureOriginal();
|
|
585
|
+
normalizeIconLinks();
|
|
511
586
|
TIMER = setInterval(poll, 1000);
|
|
512
587
|
poll();
|
|
513
588
|
// A BACKGROUND tab's timers are throttled (Chrome clamps setInterval to
|
|
@@ -695,34 +770,49 @@ function colorWarnings(states) {
|
|
|
695
770
|
}
|
|
696
771
|
|
|
697
772
|
/**
|
|
698
|
-
* Cordis plugin entry. Returns the standard `{ apply, inject,
|
|
699
|
-
* shape so the host composition can mount it once at startup.
|
|
773
|
+
* Cordis plugin entry. Returns the standard `{ apply, inject, Config }`
|
|
774
|
+
* shape so the host composition can mount it once at startup. `Config` (the
|
|
775
|
+
* schemastery schema) is what the loader validates the row against and what
|
|
776
|
+
* the host settings service projects into its live form — a plugin without it
|
|
777
|
+
* has no server-declared configuration surface at all. One bundle serves both
|
|
778
|
+
* host lines: the legacy `settings.installSection` path is feature-detected at
|
|
779
|
+
* runtime (see `apply`).
|
|
700
780
|
*/
|
|
701
781
|
export default {
|
|
702
782
|
name: "dsh-web-icon-indicator",
|
|
703
783
|
inject: ["webServer", "timer", "agents", "fs"],
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
doneHoldMs: DEFAULTS.doneHoldMs,
|
|
707
|
-
statusPath: DEFAULTS.statusPath,
|
|
708
|
-
iconPathPrefix: DEFAULTS.iconPathPrefix,
|
|
709
|
-
iconsDir: undefined,
|
|
710
|
-
defaultColor: undefined,
|
|
711
|
-
states: DEFAULTS.states,
|
|
712
|
-
},
|
|
713
|
-
/** Settings namespace + validation schema (exported for reuse/tooling). */
|
|
784
|
+
Config: CONFIG_SCHEMA,
|
|
785
|
+
/** Settings namespace (the profile entry id on ≥0.1.7) + schema, for tooling. */
|
|
714
786
|
SETTINGS_NAMESPACE,
|
|
787
|
+
/** Settings namespace used on the legacy (≤0.1.6-alpha.1) settings service. */
|
|
788
|
+
LEGACY_SETTINGS_NAMESPACE,
|
|
715
789
|
CONFIG_SCHEMA,
|
|
716
790
|
apply(ctx, config) {
|
|
717
|
-
// Cordis passes the resolved
|
|
718
|
-
// (`apply(ctx, config)`). `
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
|
|
725
|
-
|
|
791
|
+
// Cordis passes the schema-resolved config as the second argument
|
|
792
|
+
// (`apply(ctx, config)`). Every `.volatile()` field arrives as a live
|
|
793
|
+
// reference (`config.states.get()`), and the loader mutates those
|
|
794
|
+
// references in place when a settings write lands — that is what
|
|
795
|
+
// `loader/volatile-update` below reacts to. Unwrap the references once per
|
|
796
|
+
// read so the rest of this file keeps working on plain values. The
|
|
797
|
+
// `ctx.get("config")` fallback keeps the zero-dependency test harness (and
|
|
798
|
+
// any host that hands the row config through the service container) working.
|
|
799
|
+
const readRef = (value) =>
|
|
800
|
+
value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
|
|
801
|
+
const readConfig = (raw) => {
|
|
802
|
+
const out = {};
|
|
803
|
+
for (const [key, value] of Object.entries(raw || {})) {
|
|
804
|
+
const plain = readRef(value);
|
|
805
|
+
if (plain !== undefined) out[key] = plain;
|
|
806
|
+
}
|
|
807
|
+
return out;
|
|
808
|
+
};
|
|
809
|
+
// Legacy host line (DSH ≤ 0.1.6-alpha.1): `settings.installSection` owns the
|
|
810
|
+
// namespace lifecycle and hands this plugin a live source through
|
|
811
|
+
// `setSource`. Until it does, the composition config passed to `apply` (or
|
|
812
|
+
// the zero-dep test harness's `ctx.get("config")`) is the source.
|
|
813
|
+
let legacySource = null;
|
|
814
|
+
const source = () =>
|
|
815
|
+
readConfig(legacySource ? legacySource() : config ?? ctx.get("config") ?? {});
|
|
726
816
|
// Merge each configured state over its default: { effect, colors, speed }.
|
|
727
817
|
// Idempotent on schema-resolved values, so it doubles as the fallback
|
|
728
818
|
// normalization when the profile composes no settings service.
|
|
@@ -806,54 +896,72 @@ export default {
|
|
|
806
896
|
}
|
|
807
897
|
} catch (e) {}
|
|
808
898
|
};
|
|
809
|
-
let cfg = resolveConfig(
|
|
899
|
+
let cfg = resolveConfig(source());
|
|
810
900
|
logWarnings(cfg.warnings);
|
|
811
901
|
// The injected script bakes config at injection time; rebuild it whenever
|
|
812
|
-
// the
|
|
813
|
-
//
|
|
902
|
+
// the live config changes so the NEXT page load picks the new values up
|
|
903
|
+
// (reload the tab — the running tab is already carried by the status poll's
|
|
904
|
+
// `states` echo).
|
|
814
905
|
const buildScript = (c) => INJECTED_SCRIPT
|
|
815
906
|
.replace("__STATUS_PATH__", c.statusPath)
|
|
816
907
|
.replace("__BASE_PATH__", c.iconPathPrefix + "/base.svg")
|
|
817
908
|
.replace("__CFG__", JSON.stringify({ states: c.states }));
|
|
818
909
|
let script = buildScript(cfg);
|
|
819
910
|
let iconsDir = resolveIconsDir(cfg.iconsDir);
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
//
|
|
823
|
-
// schema
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
911
|
+
// Live settings, modern line (DSH ≥ 0.1.7). The host owns the whole
|
|
912
|
+
// configuration lifecycle: the loader validates the composition row against
|
|
913
|
+
// `Config`, the `settings` service (`SettingsForms`) derives the live form
|
|
914
|
+
// from the schema's `.volatile()` fields and writes them into the profile
|
|
915
|
+
// patch, and a volatile-only write is committed into the running fiber's
|
|
916
|
+
// references with a `loader/volatile-update` event instead of a restart.
|
|
917
|
+
// This listener rebuilds the cached `cfg` and the injected script from the
|
|
918
|
+
// same references; the legacy line below drives it through `onChange`
|
|
919
|
+
// instead. `statusPath` / `iconPathPrefix` are NOT volatile: they are baked
|
|
920
|
+
// into the route table and the injected script at registration time, so a
|
|
921
|
+
// document change to them re-applies the whole plugin instead (they stay
|
|
922
|
+
// outside the live form — see CONFIG_SCHEMA).
|
|
923
|
+
const refreshFromConfig = () => {
|
|
924
|
+
const next = resolveConfig(source());
|
|
925
|
+
cfg.askingHoldMs = next.askingHoldMs;
|
|
926
|
+
cfg.doneHoldMs = next.doneHoldMs;
|
|
927
|
+
if (next.iconsDir !== undefined) cfg.iconsDir = next.iconsDir;
|
|
928
|
+
cfg.states = next.states;
|
|
929
|
+
// `defaultColor` / `warnings` ride along with `states`: the color is
|
|
930
|
+
// already folded into `states.idle.colors[0]`, so the browser needs no
|
|
931
|
+
// new key — they are kept for observability (status echo + log).
|
|
932
|
+
cfg.defaultColor = next.defaultColor;
|
|
933
|
+
cfg.warnings = next.warnings;
|
|
934
|
+
iconsDir = resolveIconsDir(cfg.iconsDir);
|
|
935
|
+
script = buildScript(cfg);
|
|
936
|
+
logWarnings(cfg.warnings);
|
|
937
|
+
};
|
|
938
|
+
// A plain `ctx.on` listener is enough: the loader emits this event on the
|
|
939
|
+
// owning fiber's context only, so a volatile write to another plugin's
|
|
940
|
+
// config never reaches here. Guard for the zero-dep test harness, whose
|
|
941
|
+
// fake ctx carries no event bus.
|
|
942
|
+
if (typeof ctx.on === "function") ctx.on("loader/volatile-update", refreshFromConfig);
|
|
943
|
+
// Legacy host line (DSH ≤ 0.1.6-alpha.1): the `settings` service exposes
|
|
944
|
+
// `installSection`, and the plugin must register its namespace itself —
|
|
945
|
+
// that service method was removed in 0.1.7, where the exported `Config`
|
|
946
|
+
// schema plus `loader/volatile-update` carry the same lifecycle. Feature-
|
|
947
|
+
// detect instead of switching on a version: whichever service the running
|
|
948
|
+
// host provides decides the path, so one bundle serves both lines.
|
|
949
|
+
// The base layer is a RESOLVED PLAIN COPY (`resolveConfig(source())`), not
|
|
950
|
+
// the raw `apply` config: the legacy loader deep-freezes the config it
|
|
951
|
+
// resolved against the exported `Config` schema, and the legacy settings
|
|
952
|
+
// service resolves the schema over the base IN PLACE (its `mergeLayers`
|
|
953
|
+
// returns the base unchanged when no user section exists) — handing it a
|
|
954
|
+
// frozen object throws "Cannot assign to read only property".
|
|
955
|
+
if (typeof ctx.inject === "function") {
|
|
956
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
957
|
+
const settings = settingsCtx.settings;
|
|
958
|
+
if (!settings || typeof settings.installSection !== "function") return;
|
|
959
|
+
settings.installSection(ctx, LEGACY_SETTINGS_NAMESPACE, CONFIG_SCHEMA, resolveConfig(source()), {
|
|
960
|
+
setSource: (next) => { legacySource = next; },
|
|
961
|
+
onChange: refreshFromConfig,
|
|
962
|
+
});
|
|
855
963
|
});
|
|
856
|
-
}
|
|
964
|
+
}
|
|
857
965
|
const webServer = ctx.webServer;
|
|
858
966
|
const agents = ctx.agents;
|
|
859
967
|
const fs = ctx.fs;
|