@kehto/shell 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,7 @@ Current draft behaviors this package enforces:
34
34
  when the runtime can identify it. The shell forwards this optional context
35
35
  without imposing a consent policy; host runtimes may ignore it or use it to
36
36
  resolve their own identity-scoped signer decisions.
37
- - `prepareNappletSrcdoc()` inserts validated CSP first, then the NIP-5D bootstrap and mandatory NAP-SHELL shim, outside verified artifact bytes. The shim installs the parent-bound `shell.init` receiver, emits one `shell.ready`, and exposes callable NAP interfaces before authored scripts run. Optional namespaces are filtered to the bare-domain allowlist; `shell` is always retained. Published core 0.32.0 and shim 0.30.0 omit generic mandatory shell, so Kehto retains this host-owned prelude under NAP-SHELL `5ac0490461ca6fec2f0d2e45b4835cf9bc08de24` until an upstream correction is reviewed.
37
+ - `prepareNappletSrcdoc()` inserts validated CSP and the runtime namespace outside verified artifact bytes. It exposes only the host-selected bare domains. Optional `shell` installs its parent-bound receiver before readiness; routing uses trusted iframe creation identity independently of that exchange. See the living [NAP-SHELL](https://github.com/napplet/naps/blob/master/naps/NAP-SHELL.md).
38
38
  - The injected resource projection follows draft NAP-RESOURCE `9511232f69313aa7953d110e35d32cc28d506f66`: `bytes(url, { servers? })` carries advisory Blossom locations and `bytesMany(requests)` keeps each `{ url, servers? }` entry intact.
39
39
  - `window.napplet.shell.supports(domain)` answers synchronously and locally from the cached first `shell.init` environment. It returns `false` before `shell.init`, for unknown values, and for domains that are not live and granted to that napplet; it never sends a support-query message.
40
40
  - Five optional per-domain proxies — `createIdentityProxy`, `createThemeProxy`, `createKeysProxy`, `createMediaProxy`, `createNotifyProxy` — can be composed between napplet and runtime to intercept request traffic per NAP. They are NOT wired by default. Identity/theme proxy `emit()` compatibility members fail closed; hosts must deliver automatic changes through `ShellBridge.publishIdentityChanged()` / `publishTheme()`, which enforce live session, granted domain, and current ACL.
@@ -198,7 +198,7 @@ automatic changes are accepted only when their `MessageEvent.source` is
198
198
  the canonical operations.
199
199
 
200
200
  `ShellBridge` delivers `identity.changed` and `theme.changed` exactly once per
201
- eligible recipient: a live authenticated `shell.ready` session, its frozen
201
+ eligible recipient: a live authenticated source-bound session, its frozen
202
202
  environment includes the relevant domain, and its current recipient read
203
203
  capability is still granted. Identity sign-out is `pubkey: ""`. Theme updates
204
204
  arrive only after the service has stored the complete color state. These are
@@ -283,3 +283,7 @@ contract, as documented in the conformance policy.
283
283
  The shell normalizer also powers Paja intent links. See the
284
284
  [Paja guide](https://kehto.github.io/web/docs/packages/paja.html#nap-intent-links-and-lifecycle)
285
285
  for outer URL encoding, review, routing, and verified launcher behavior.
286
+
287
+ ## GAMEPAD integration
288
+
289
+ This non-normative guidance defers to the living [NAP-GAMEPAD](https://github.com/napplet/naps/pull/108). Services export `createGamepadService` and `browserGamepadOptions`; shell exports `denyNappletControllerAccess`. Paja and the playground register one host reader and deny native gamepad/HID/USB paths before frame execution. Source-bound subscriptions receive immediate full snapshots; only the visible host's input owner gets live controls. Focus loss supplies neutral controls and timestamp zero; unsubscribe, window destruction, and service removal release polling. Non-browser or denied native readers report the proposal's unavailable reasons. See [Controller input](https://github.com/kehto/web/blob/main/docs/gamepad.md).
package/dist/index.d.ts CHANGED
@@ -327,7 +327,7 @@ interface UnroutedMessageInfo {
327
327
  /**
328
328
  * Static capability set sent to napplet iframes through the shell.ready /
329
329
  * shell.init handshake. Optional runtime domain presence is injected before
330
- * authored code, while mandatory NAP-SHELL caches this environment for local
330
+ * authored code, while optional NAP-SHELL caches this environment for local
331
331
  * capability queries.
332
332
  *
333
333
  * NAP-SHELL needs only a truthful set of bare domain names. The shim answers
@@ -584,6 +584,14 @@ interface ShellBridge {
584
584
  }
585
585
  declare function createShellBridge(hooks: ShellAdapter): ShellBridge;
586
586
 
587
+ /**
588
+ * Restrict native controller/device APIs before assigning a napplet document.
589
+ * @param frame Host-owned iframe, before its authored code executes.
590
+ * @returns Nothing; unrelated Permissions-Policy entries are retained.
591
+ * @example denyNappletControllerAccess(frame);
592
+ */
593
+ declare function denyNappletControllerAccess(frame: Pick<HTMLIFrameElement, 'getAttribute' | 'setAttribute'>): void;
594
+
587
595
  /**
588
596
  * The injected implementation must remain one self-contained function: the
589
597
  * renderer serializes it ahead of verified artifact scripts without changing
@@ -605,7 +613,7 @@ declare function createShellBridge(hooks: ShellAdapter): ShellBridge;
605
613
  interface NappletNamespacePreludeOptions {
606
614
  /**
607
615
  * Optional bare NAP domain names the shell exposes to this napplet.
608
- * Mandatory `shell` is injected even when omitted here.
616
+ * Shell is injected only when included in the exposed domains.
609
617
  */
610
618
  domains: readonly string[];
611
619
  }
@@ -1560,4 +1568,4 @@ type ResourceInbound = ResourceInfoRequest | ResourceBytesRequest | ResourceByte
1560
1568
  */
1561
1569
  type ResourceOutbound = ResourceInfoResult | ResourceInfoError | ResourceBytesResult | ResourceBytesError | ResourceBytesManyResult | ResourceBytesManyError;
1562
1570
 
1563
- export { type AclCheckEvent, type AclEntry, type AclStore, type AudioManager, type AudioSource, type AuthHooks, type BleHooks, type BrowserDeps, type CapabilityHooks, type CommonHooks, type ConfigHooks, type CryptoHooks, type DmHooks, type HotkeyHooks, type IdentityProxy, type IdentityProxyDeps, type IntentHooks, type KeysProxy, type KeysProxyDeps, type LinkHooks, type ListsHooks, type ManifestCache, type ManifestCacheEntry, type MediaProxy, type MediaProxyDeps, type NappletCspDirective, type NappletCspDirectives, type NappletCspOptions, type NappletNamespacePreludeOptions, type NappletSrcdocOptions, type NormalizedIntentUri, type NotifyProxy, type NotifyProxyDeps, type OriginIdentity, type OriginRegistry, type ProxyOriginRegistry, type RelayConfigHooks, type RelayPoolHooks, type RelayPoolLike, type ResourceBytesError, type ResourceBytesManyError, type ResourceBytesManyItem, type ResourceBytesManyRequest, type ResourceBytesManyResult, type ResourceBytesRequest, type ResourceBytesResult, type ResourceCancelRequest, type ResourceErrorCode, type ResourceInbound, type ResourceInfoError, type ResourceInfoRequest, type ResourceInfoResult, type ResourceOutbound, type ResourceRequestId, type SerialHooks, type SessionRegistry, type ShellAdapter, type ShellBridge, type ShellCapabilities, type ShellEnvironment, type ThemeProxy, type ThemeProxyDeps, type UnroutedMessageInfo, type UploadBackendLike, type UploadHooks, type WebrtcHooks, type WindowManagerHooks, type WorkerRelayHooks, type WorkerRelayLike, adaptHooks, audioManager, buildNappletCsp, buildShellCapabilities, createIdentityProxy, createKeysProxy, createMediaProxy, createNotifyProxy, createShellBridge, createThemeProxy, injectNappletCsp, injectNappletNamespacePrelude, manifestCache, nappKeyRegistry, normalizeIntentUri, originRegistry, prepareNappletSrcdoc, renderNappletCspMeta, renderNappletNamespacePrelude, resolveShellEnvironment, sessionRegistry };
1571
+ export { type AclCheckEvent, type AclEntry, type AclStore, type AudioManager, type AudioSource, type AuthHooks, type BleHooks, type BrowserDeps, type CapabilityHooks, type CommonHooks, type ConfigHooks, type CryptoHooks, type DmHooks, type HotkeyHooks, type IdentityProxy, type IdentityProxyDeps, type IntentHooks, type KeysProxy, type KeysProxyDeps, type LinkHooks, type ListsHooks, type ManifestCache, type ManifestCacheEntry, type MediaProxy, type MediaProxyDeps, type NappletCspDirective, type NappletCspDirectives, type NappletCspOptions, type NappletNamespacePreludeOptions, type NappletSrcdocOptions, type NormalizedIntentUri, type NotifyProxy, type NotifyProxyDeps, type OriginIdentity, type OriginRegistry, type ProxyOriginRegistry, type RelayConfigHooks, type RelayPoolHooks, type RelayPoolLike, type ResourceBytesError, type ResourceBytesManyError, type ResourceBytesManyItem, type ResourceBytesManyRequest, type ResourceBytesManyResult, type ResourceBytesRequest, type ResourceBytesResult, type ResourceCancelRequest, type ResourceErrorCode, type ResourceInbound, type ResourceInfoError, type ResourceInfoRequest, type ResourceInfoResult, type ResourceOutbound, type ResourceRequestId, type SerialHooks, type SessionRegistry, type ShellAdapter, type ShellBridge, type ShellCapabilities, type ShellEnvironment, type ThemeProxy, type ThemeProxyDeps, type UnroutedMessageInfo, type UploadBackendLike, type UploadHooks, type WebrtcHooks, type WindowManagerHooks, type WorkerRelayHooks, type WorkerRelayLike, adaptHooks, audioManager, buildNappletCsp, buildShellCapabilities, createIdentityProxy, createKeysProxy, createMediaProxy, createNotifyProxy, createShellBridge, createThemeProxy, denyNappletControllerAccess, injectNappletCsp, injectNappletNamespacePrelude, manifestCache, nappKeyRegistry, normalizeIntentUri, originRegistry, prepareNappletSrcdoc, renderNappletCspMeta, renderNappletNamespacePrelude, resolveShellEnvironment, sessionRegistry };
package/dist/index.js CHANGED
@@ -976,6 +976,7 @@ var audioManager = {
976
976
 
977
977
  // src/shell-init.ts
978
978
  var RUNTIME_NATIVE_DOMAINS = [
979
+ "shell",
979
980
  "relay",
980
981
  "identity",
981
982
  "storage",
@@ -1000,7 +1001,8 @@ var SERVICE_ONLY_DOMAINS = [
1000
1001
  "webrtc",
1001
1002
  "dm",
1002
1003
  "count",
1003
- "fs"
1004
+ "fs",
1005
+ "gamepad"
1004
1006
  ];
1005
1007
  function buildShellCapabilities(hooks) {
1006
1008
  const disabled = new Set(hooks.capabilities?.disabledDomains ?? []);
@@ -1096,14 +1098,21 @@ function handleShellReady({
1096
1098
  if (state.initSent.get(sourceWindow) === sourceRegistrationId) {
1097
1099
  return;
1098
1100
  }
1099
- const identity = resolveNip5dIdentity(hooks, windowId, sourceWindow);
1100
- if (!identity) return;
1101
- registerNip5dSessionIfNeeded({ origin, runtime, state, sourceRegistrationId, windowId, identity });
1102
- const environment = originRegistry.getEnvironment(sourceWindow) ?? resolveShellEnvironment(hooks, identity);
1103
- state.environments.set(windowId, environment);
1101
+ const environment = registerSourceSession({ hooks, origin, runtime, state, sourceRegistrationId, sourceWindow, windowId });
1102
+ if (!environment?.capabilities.domains.includes("shell")) return;
1104
1103
  postShellInit(sourceWindow, environment);
1105
1104
  state.initSent.set(sourceWindow, sourceRegistrationId);
1106
1105
  }
1106
+ function registerSourceSession(options) {
1107
+ const { hooks, origin, runtime, state, sourceRegistrationId, sourceWindow, windowId } = options;
1108
+ const identity = resolveNip5dIdentity(hooks, windowId, sourceWindow, runtime.sessionRegistry.getEntryByWindowId(windowId));
1109
+ if (!identity) return null;
1110
+ registerNip5dSessionIfNeeded({ origin, runtime, state, sourceRegistrationId, windowId, identity });
1111
+ const existing = state.environments.get(windowId);
1112
+ const environment = originRegistry.getEnvironment(sourceWindow) ?? existing ?? resolveShellEnvironment(hooks, identity);
1113
+ state.environments.set(windowId, environment);
1114
+ return environment;
1115
+ }
1107
1116
  function registerNip5dSessionIfNeeded({
1108
1117
  origin,
1109
1118
  runtime,
@@ -1118,9 +1127,14 @@ function registerNip5dSessionIfNeeded({
1118
1127
  return;
1119
1128
  }
1120
1129
  if (existing && previousRegistration !== void 0) {
1130
+ runtime.destroyWindow(windowId);
1121
1131
  runtime.sessionRegistry.unregister(windowId);
1132
+ state.environments.delete(windowId);
1133
+ }
1134
+ if (existing && previousRegistration === void 0) {
1135
+ state.sessionRegistration.set(windowId, sourceRegistrationId);
1136
+ return;
1122
1137
  }
1123
- if (existing && previousRegistration === void 0) return;
1124
1138
  const entry = {
1125
1139
  pubkey: "",
1126
1140
  windowId,
@@ -1135,7 +1149,7 @@ function registerNip5dSessionIfNeeded({
1135
1149
  runtime.sessionRegistry.register(windowId, entry);
1136
1150
  state.sessionRegistration.set(windowId, sourceRegistrationId);
1137
1151
  }
1138
- function resolveNip5dIdentity(hooks, windowId, sourceWindow) {
1152
+ function resolveNip5dIdentity(hooks, windowId, sourceWindow, existingSession) {
1139
1153
  const hookIdentity = hooks.onNip5dIframeCreate?.(windowId);
1140
1154
  if (hookIdentity !== null && hookIdentity !== void 0) {
1141
1155
  return Object.freeze({
@@ -1145,7 +1159,8 @@ function resolveNip5dIdentity(hooks, windowId, sourceWindow) {
1145
1159
  }
1146
1160
  const originIdentity = originRegistry.getIdentity(sourceWindow);
1147
1161
  if (!originIdentity) {
1148
- return null;
1162
+ if (existingSession?.provenance !== "nip-5d" || typeof existingSession.dTag !== "string" || typeof existingSession.aggregateHash !== "string") return null;
1163
+ return Object.freeze({ dTag: existingSession.dTag, aggregateHash: existingSession.aggregateHash });
1149
1164
  }
1150
1165
  return Object.freeze({
1151
1166
  dTag: originIdentity.dTag,
@@ -1208,6 +1223,15 @@ function createShellBridge(hooks) {
1208
1223
  }
1209
1224
  const msg = event.data;
1210
1225
  if (typeof msg !== "object" || msg === null || typeof msg.type !== "string") return;
1226
+ if (!registerSourceSession({
1227
+ hooks,
1228
+ origin: event.origin,
1229
+ runtime,
1230
+ state: shellReadyState,
1231
+ sourceRegistrationId: originRegistry.getRegistrationId(sourceWindow) ?? 0,
1232
+ sourceWindow,
1233
+ windowId
1234
+ })) return;
1211
1235
  if (msg.type === "shell.ready") {
1212
1236
  handleShellReady({
1213
1237
  hooks,
@@ -1246,6 +1270,13 @@ function createShellBridge(hooks) {
1246
1270
  };
1247
1271
  }
1248
1272
 
1273
+ // src/gamepad-policy.ts
1274
+ var CONTROLLER_FEATURES = ["gamepad", "hid", "usb"];
1275
+ function denyNappletControllerAccess(frame) {
1276
+ const remaining = (frame.getAttribute("allow") ?? "").split(";").map((entry) => entry.trim()).filter((entry) => entry && !CONTROLLER_FEATURES.some((feature) => entry === feature || entry.startsWith(`${feature} `)));
1277
+ frame.setAttribute("allow", [...remaining, ...CONTROLLER_FEATURES.map((feature) => `${feature} 'none'`)].join("; "));
1278
+ }
1279
+
1249
1280
  // src/intent-uri.ts
1250
1281
  function createIntentUriNormalizer() {
1251
1282
  const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
@@ -1439,7 +1470,7 @@ function scriptJson(value) {
1439
1470
  return JSON.stringify(value).replace(/</g, "\\u003c");
1440
1471
  }
1441
1472
  function renderNappletNamespacePrelude(options) {
1442
- const domains = uniqueBareDomains(["shell", ...options.domains]);
1473
+ const domains = uniqueBareDomains(options.domains);
1443
1474
  return `<script data-kehto-nip5d-injection>(${nappletNamespacePrelude.toString()})(${scriptJson(domains)}, (${createIntentUriNormalizer.toString()})());</script>`;
1444
1475
  }
1445
1476
  function injectNappletNamespacePrelude(html, options) {
@@ -1628,7 +1659,7 @@ function nappletNamespacePrelude(domains, normalizeIntentUri2) {
1628
1659
  return Object.freeze(strings);
1629
1660
  }
1630
1661
  function supports(domain) {
1631
- return typeof domain === "string" && environment?.capabilities.domains.includes(domain) === true;
1662
+ return typeof domain === "string" && !!target.napplet && Object.prototype.hasOwnProperty.call(target.napplet, domain);
1632
1663
  }
1633
1664
  const shell2 = {
1634
1665
  ready: () => ready,
@@ -2709,6 +2740,63 @@ function nappletNamespacePrelude(domains, normalizeIntentUri2) {
2709
2740
  })
2710
2741
  };
2711
2742
  }
2743
+ function makeGamepad() {
2744
+ let state = { available: true, focused: false, pads: [] };
2745
+ const handlers = /* @__PURE__ */ new Set();
2746
+ const copy = (value) => ({
2747
+ available: value.available,
2748
+ focused: value.focused,
2749
+ ...value.reason === void 0 ? {} : { reason: value.reason },
2750
+ pads: value.pads.map((pad) => pad === null ? null : {
2751
+ index: pad.index,
2752
+ id: pad.id,
2753
+ mapping: pad.mapping,
2754
+ connected: true,
2755
+ timestamp: pad.timestamp,
2756
+ axes: [...pad.axes],
2757
+ buttons: pad.buttons.map(({ value: value2, pressed, touched }) => ({ value: value2, pressed, touched }))
2758
+ })
2759
+ });
2760
+ const numberIn = (value, min, max) => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
2761
+ const valid = (value) => {
2762
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
2763
+ const candidate = value;
2764
+ if (typeof candidate.available !== "boolean" || typeof candidate.focused !== "boolean" || !Array.isArray(candidate.pads)) return false;
2765
+ if (candidate.available ? candidate.reason !== void 0 : candidate.focused || !["blocked", "unavailable"].includes(candidate.reason ?? "")) return false;
2766
+ return Array.from(candidate.pads).every((pad) => {
2767
+ if (pad === null) return true;
2768
+ if (!pad || typeof pad !== "object" || !Number.isInteger(pad.index) || pad.index < 0 || typeof pad.id !== "string" || typeof pad.mapping !== "string" || pad.connected !== true || !Number.isFinite(pad.timestamp) || !Array.isArray(pad.axes) || !Array.isArray(pad.buttons)) return false;
2769
+ if (!candidate.focused && pad.timestamp !== 0) return false;
2770
+ if (!Array.from(pad.axes).every((axis) => numberIn(axis, -1, 1) && (candidate.focused || axis === 0))) return false;
2771
+ return Array.from(pad.buttons).every((button) => button && numberIn(button.value, 0, 1) && typeof button.pressed === "boolean" && typeof button.touched === "boolean" && (candidate.focused || button.value === 0 && !button.pressed && !button.touched));
2772
+ });
2773
+ };
2774
+ listen((event) => {
2775
+ if (!isParentMessage(event)) return;
2776
+ const incoming = event.data;
2777
+ if (!incoming || incoming.type !== "gamepad.state" || "id" in incoming || !valid(incoming)) return;
2778
+ state = copy(incoming);
2779
+ const snapshot = copy(state);
2780
+ const recipients = Array.from(handlers);
2781
+ for (const entry of recipients) if (handlers.has(entry)) entry.handler(copy(snapshot));
2782
+ });
2783
+ const api = {
2784
+ get available() {
2785
+ return state.available;
2786
+ },
2787
+ get focused() {
2788
+ return state.focused;
2789
+ },
2790
+ getGamepads: () => copy(state).pads,
2791
+ onChange(handler) {
2792
+ const entry = { handler };
2793
+ handlers.add(entry);
2794
+ return subscriptionHandle(() => handlers.delete(entry));
2795
+ }
2796
+ };
2797
+ post({ type: "gamepad.subscribe" });
2798
+ return api;
2799
+ }
2712
2800
  function makeDm() {
2713
2801
  const req = (type, payload) => request({ type, ...payload }, `${type}.result`, withoutEnvelope, { rejectOnError: false });
2714
2802
  return {
@@ -2755,7 +2843,8 @@ function nappletNamespacePrelude(domains, normalizeIntentUri2) {
2755
2843
  };
2756
2844
  }
2757
2845
  const intentDelivery = allowed.has("intent") ? makeIntentDelivery() : void 0;
2758
- const shell = makeShell();
2846
+ const shell = allowed.has("shell") ? makeShell() : void 0;
2847
+ let gamepad;
2759
2848
  let inc;
2760
2849
  let identity;
2761
2850
  let theme;
@@ -2778,9 +2867,10 @@ function nappletNamespacePrelude(domains, normalizeIntentUri2) {
2778
2867
  return intent;
2779
2868
  }
2780
2869
  function isProtectedDomain(domain) {
2781
- return domain === "shell" || domain === "inc" || domain === "identity" || domain === "theme" || domain === "intent";
2870
+ return domain === "gamepad" || domain === "shell" || domain === "inc" || domain === "identity" || domain === "theme" || domain === "intent";
2782
2871
  }
2783
2872
  function makeDomain(domain, existing) {
2873
+ if (domain === "gamepad") return gamepad ??= makeGamepad();
2784
2874
  if (domain === "shell") return shell;
2785
2875
  if (domain === "inc") return makeProtectedInc(existing);
2786
2876
  if (domain === "identity") return makeProtectedIdentity();
@@ -3094,6 +3184,7 @@ export {
3094
3184
  createNotifyProxy,
3095
3185
  createShellBridge,
3096
3186
  createThemeProxy,
3187
+ denyNappletControllerAccess,
3097
3188
  formatDenialReason,
3098
3189
  injectNappletCsp,
3099
3190
  injectNappletNamespacePrelude,