@napplet/conformance-cli 0.3.3 → 0.4.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.
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  var NappletConformanceHostBundle = (() => {
3
3
  // ../core/dist/index.js
4
- var NAP_DOMAINS = ["shell", "relay", "identity", "storage", "inc", "theme", "keys", "media", "notify", "config", "resource", "cvm", "outbox", "upload", "intent", "ble", "webrtc", "link", "count", "lists", "serial", "fs", "common", "dm"];
4
+ var NAP_DOMAINS = ["shell", "relay", "identity", "storage", "inc", "theme", "keys", "media", "notify", "config", "resource", "cvm", "outbox", "upload", "intent", "ble", "webrtc", "link", "count", "lists", "serial", "fs", "common", "dm", "gamepad"];
5
5
  function createDispatch() {
6
6
  const handlers = /* @__PURE__ */ new Map();
7
7
  function registerNap2(domain, handler) {
@@ -32,6 +32,11 @@ var NappletConformanceHostBundle = (() => {
32
32
  // ../conformance/dist/index.js
33
33
  var ID = { id: "string" };
34
34
  var ENVELOPE_SPECS = {
35
+ // NAP-GAMEPAD Wire Protocol: all three messages are fire-and-forget, without ids.
36
+ // https://github.com/napplet/naps/pull/108
37
+ "gamepad.subscribe": { dir: "out", forbiddenFields: ["id"], forbiddenReason: "NAP-GAMEPAD fire-and-forget envelopes do not carry it" },
38
+ "gamepad.unsubscribe": { dir: "out", forbiddenFields: ["id"], forbiddenReason: "NAP-GAMEPAD fire-and-forget envelopes do not carry it" },
39
+ "gamepad.state": { dir: "in", fields: { available: "boolean", focused: "boolean", pads: "array" } },
35
40
  // Optional NAP-SHELL environment, independent of other domains.
36
41
  "shell.ready": { dir: "out" },
37
42
  "shell.init": { dir: "in", fields: { capabilities: "object", services: "array" } },
@@ -462,7 +467,7 @@ var NappletConformanceHostBundle = (() => {
462
467
  if (field in record) {
463
468
  errors.push({
464
469
  code: "forbidden-field",
465
- message: `Field "${field}" must be runtime-derived and cannot be emitted by a napplet`,
470
+ message: `Field "${field}" cannot be emitted by a napplet: ${spec.forbiddenReason ?? "it must be runtime-derived"}`,
466
471
  field
467
472
  });
468
473
  }
@@ -564,6 +569,11 @@ var NappletConformanceHostBundle = (() => {
564
569
  errors.push({ code: "invalid-metadata", message: `Invalid ${name} tag cardinality for kind ${event.kind}` });
565
570
  }
566
571
  }
572
+ for (const tag of event.tags.filter((tag2) => tag2[0] === "source")) {
573
+ if (tag.length !== 2 || !isSourceLocation(tag[1])) {
574
+ errors.push({ code: "invalid-metadata", message: "source must contain one HTTP(S), SSH, Git, or SCP repository location" });
575
+ }
576
+ }
567
577
  for (const tag of event.tags.filter((tag2) => tag2[0] === "i")) {
568
578
  if (!tag[1]?.trim() || tag[1].includes("?")) {
569
579
  errors.push({ code: "invalid-metadata", message: "Intent advertisements must be non-empty queryless identities" });
@@ -574,6 +584,16 @@ var NappletConformanceHostBundle = (() => {
574
584
  warnings.push({ code: "invalid-icon", message: "Ignore invalid icon metadata and use generic artwork (NIP-5D \xA7Icon)" });
575
585
  }
576
586
  }
587
+ function isSourceLocation(value) {
588
+ if (!value || /^[a-z]:[\\/]/i.test(value)) return false;
589
+ try {
590
+ const { protocol } = new URL(value);
591
+ if (["http:", "https:", "ssh:", "git:"].includes(protocol)) return true;
592
+ if (protocol === "file:") return false;
593
+ } catch {
594
+ }
595
+ return !value.includes("://") && /^(?:[^@\s/:]+@)?[^:\s/]+:[^\s]+$/.test(value);
596
+ }
577
597
  var REFERENCE_PUBKEY = "f".repeat(64);
578
598
  var REFERENCE_ENDPOINT = { id: "reference-source-endpoint", catalogId: "reference-source" };
579
599
  var REFERENCE_BLOB_URL = "https://reference.invalid/blob";
@@ -609,6 +629,9 @@ var NappletConformanceHostBundle = (() => {
609
629
  }
610
630
  }
611
631
  var RESPONDERS = {
632
+ // This transport-neutral mock has no controller API. Production shells use a host reader.
633
+ "gamepad.subscribe": () => ok({ type: "gamepad.state", available: false, reason: "unavailable", focused: false, pads: [] }),
634
+ "gamepad.unsubscribe": none,
612
635
  // relay
613
636
  "relay.subscribe": (e) => ok({ type: "relay.eose", subId: e.subId }),
614
637
  "relay.close": none,
@@ -971,18 +994,76 @@ var NappletConformanceHostBundle = (() => {
971
994
  };
972
995
  }
973
996
  var INTERNAL_BOOT_ERROR = "__nappletConformance.error";
997
+ var INTERNAL_INSTALLED = "__nappletConformance.installed";
974
998
  function sleep(ms) {
975
999
  return new Promise((resolve) => setTimeout(resolve, ms));
976
1000
  }
977
1001
  function escapeHtmlAttr(value) {
978
1002
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
979
1003
  }
980
- function runtimePrelude(domains) {
1004
+ var GAMEPAD_STATE_TOOLS_SOURCE = `(() => {
1005
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
1006
+ const numberIn = (value, min, max) => typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max;
1007
+ const isGamepadState = (value) => {
1008
+ if (!record(value) || typeof value.available !== 'boolean' || typeof value.focused !== 'boolean' || !Array.isArray(value.pads)) return false;
1009
+ if (value.available) {
1010
+ if (value.reason !== undefined) return false;
1011
+ } else if (value.focused || (value.reason !== 'blocked' && value.reason !== 'unavailable')) return false;
1012
+ return Array.from(value.pads).every((pad) => {
1013
+ if (pad === null) return true;
1014
+ if (!record(pad) || !Number.isInteger(pad.index) || pad.index < 0 || typeof pad.id !== 'string' || typeof pad.mapping !== 'string' || pad.connected !== true) return false;
1015
+ if (typeof pad.timestamp !== 'number' || !Number.isFinite(pad.timestamp) || !Array.isArray(pad.axes) || !Array.isArray(pad.buttons)) return false;
1016
+ if (!value.focused && pad.timestamp !== 0) return false;
1017
+ if (!Array.from(pad.axes).every((axis) => numberIn(axis, -1, 1) && (value.focused || axis === 0))) return false;
1018
+ return Array.from(pad.buttons).every((button) => record(button) && numberIn(button.value, 0, 1) && typeof button.pressed === 'boolean' && typeof button.touched === 'boolean' && (value.focused || (button.value === 0 && !button.pressed && !button.touched)));
1019
+ });
1020
+ };
1021
+ const copyGamepadState = (state) => {
1022
+ const copy = { available: state.available, focused: state.focused };
1023
+ if (state.reason !== undefined) copy.reason = state.reason;
1024
+ copy.pads = state.pads.map((pad) => pad === null ? null : {
1025
+ index: pad.index, id: pad.id, mapping: pad.mapping, connected: pad.connected,
1026
+ timestamp: pad.timestamp, axes: pad.axes.slice(), buttons: pad.buttons.map((button) => ({ value: button.value, pressed: button.pressed, touched: button.touched })),
1027
+ });
1028
+ return copy;
1029
+ };
1030
+ return { isGamepadState, copyGamepadState };
1031
+ })()`;
1032
+ function errorObserver() {
1033
+ return `(() => {
1034
+ const report = (reason) => {
1035
+ try { window.parent.postMessage({ type: '${INTERNAL_BOOT_ERROR}', reason: String(reason && reason.message || reason) }, '*'); }
1036
+ catch {}
1037
+ };
1038
+ window.addEventListener('error', (event) => report(event.error || event.message));
1039
+ window.addEventListener('unhandledrejection', (event) => report(event.reason));
1040
+ })();`;
1041
+ }
1042
+ function runtimePrelude(domains, runtimePreludeSource) {
1043
+ const installed = `window.parent.postMessage({ type: '${INTERNAL_INSTALLED}', installed: !!window.napplet }, '*');`;
1044
+ if (runtimePreludeSource !== void 0) {
1045
+ return `${runtimePreludeSource}
1046
+ ;globalThis.NappletShimPrelude.install(${JSON.stringify({ domains })});
1047
+ ${installed}`;
1048
+ }
981
1049
  return `
982
1050
  (() => {
983
1051
  const domains = ${JSON.stringify(domains)};
984
1052
  const napplet = {};
985
- for (const domain of domains) napplet[domain] = {};
1053
+ for (const domain of domains) {
1054
+ if (domain === 'resource' || domain === 'gamepad') napplet[domain] = {};
1055
+ }
1056
+ const gamepadTools = ${GAMEPAD_STATE_TOOLS_SOURCE};
1057
+ let gamepadState = { available: true, focused: false, pads: [] };
1058
+ const gamepadHandlers = new Set();
1059
+ // Subscribe on first use, like the nap binding, so napplets that never touch
1060
+ // gamepad emit no gamepad traffic for checks to attribute to them.
1061
+ let gamepadSubscribed = false;
1062
+ const gamepadSubscribe = () => {
1063
+ if (gamepadSubscribed) return;
1064
+ gamepadSubscribed = true;
1065
+ window.parent.postMessage({ type: 'gamepad.subscribe' }, '*');
1066
+ };
986
1067
  const pending = new Map();
987
1068
  let seq = 0;
988
1069
  const nextId = () => {
@@ -1002,7 +1083,18 @@ var NappletConformanceHostBundle = (() => {
1002
1083
  window.parent.postMessage({ ...message, id }, '*');
1003
1084
  });
1004
1085
  window.addEventListener('message', (event) => {
1086
+ if (event.source !== window.parent) return;
1005
1087
  const msg = event.data;
1088
+ if (gamepadSubscribed && msg && msg.type === 'gamepad.state' && !('id' in msg) && gamepadTools.isGamepadState(msg)) {
1089
+ gamepadState = gamepadTools.copyGamepadState(msg);
1090
+ const recipients = Array.from(gamepadHandlers);
1091
+ for (const entry of recipients) {
1092
+ if (!gamepadHandlers.has(entry)) continue;
1093
+ try { entry.handler(gamepadTools.copyGamepadState(gamepadState)); }
1094
+ catch (error) { queueMicrotask(() => { throw error; }); }
1095
+ }
1096
+ return;
1097
+ }
1006
1098
  if (!msg || typeof msg !== 'object' || typeof msg.id !== 'string') return;
1007
1099
  const slot = pending.get(msg.id);
1008
1100
  if (!slot) return;
@@ -1015,6 +1107,19 @@ var NappletConformanceHostBundle = (() => {
1015
1107
  }
1016
1108
  slot.resolve(msg);
1017
1109
  });
1110
+ if (napplet.gamepad) {
1111
+ napplet.gamepad = {
1112
+ get available() { gamepadSubscribe(); return gamepadState.available; },
1113
+ get focused() { gamepadSubscribe(); return gamepadState.focused; },
1114
+ getGamepads() { gamepadSubscribe(); return gamepadTools.copyGamepadState(gamepadState).pads; },
1115
+ onChange(handler) {
1116
+ const entry = { handler };
1117
+ gamepadHandlers.add(entry);
1118
+ gamepadSubscribe();
1119
+ return { close() { gamepadHandlers.delete(entry); } };
1120
+ },
1121
+ };
1122
+ }
1018
1123
  if (napplet.resource) {
1019
1124
  napplet.resource.info = () =>
1020
1125
  request({ type: 'resource.info' }, 'resource.info.result', 'resource.info.error')
@@ -1048,19 +1153,14 @@ var NappletConformanceHostBundle = (() => {
1048
1153
  };
1049
1154
  }
1050
1155
  window.napplet = napplet;
1051
- const report = (reason) => {
1052
- try {
1053
- window.parent.postMessage({ type: '${INTERNAL_BOOT_ERROR}', reason: String(reason && reason.message || reason) }, '*');
1054
- } catch {}
1055
- };
1056
- window.addEventListener('error', (event) => report(event.error || event.message));
1057
- window.addEventListener('unhandledrejection', (event) => report(event.reason));
1156
+ ${installed}
1058
1157
  })();
1059
1158
  `;
1060
1159
  }
1061
- function injectRuntime(html, url, domains) {
1160
+ function injectRuntime(html, url, domains, runtimePreludeSource) {
1062
1161
  const base = `<base href="${escapeHtmlAttr(url)}">`;
1063
- const script = `<script>${runtimePrelude(domains)}<\/script>`;
1162
+ const source = runtimePrelude(domains, runtimePreludeSource).replace(/<\/script/gi, "<\\/script");
1163
+ const script = `<script>${errorObserver()}<\/script><script>${source}<\/script>`;
1064
1164
  const headOpen = /<head(?:\s[^>]*)?>/i.exec(html);
1065
1165
  if (headOpen) {
1066
1166
  const at = headOpen.index + headOpen[0].length;
@@ -1089,15 +1189,21 @@ var NappletConformanceHostBundle = (() => {
1089
1189
  const shell = createReferenceShell();
1090
1190
  const iframe = doc.createElement("iframe");
1091
1191
  iframe.setAttribute("sandbox", opts.allowSameOrigin ? "allow-scripts allow-same-origin" : "allow-scripts");
1192
+ iframe.setAttribute("allow", "gamepad 'none'");
1092
1193
  iframe.setAttribute("aria-hidden", "true");
1093
1194
  iframe.style.position = "absolute";
1094
1195
  iframe.style.width = "1px";
1095
1196
  iframe.style.height = "1px";
1096
1197
  iframe.style.left = "-9999px";
1097
1198
  let bootError = null;
1199
+ let installedGlobal = false;
1098
1200
  const listener = (event) => {
1099
1201
  if (event.source !== iframe.contentWindow) return;
1100
1202
  const data = event.data;
1203
+ if (data && typeof data === "object" && data.type === INTERNAL_INSTALLED) {
1204
+ installedGlobal = data.installed === true;
1205
+ return;
1206
+ }
1101
1207
  if (data && typeof data === "object" && data.type === INTERNAL_BOOT_ERROR) {
1102
1208
  bootError = `napplet boot error: ${String(data.reason ?? "unknown error")}`;
1103
1209
  return;
@@ -1114,10 +1220,10 @@ var NappletConformanceHostBundle = (() => {
1114
1220
  }, { once: true });
1115
1221
  });
1116
1222
  win.addEventListener("message", listener);
1117
- (doc.body ?? doc.documentElement).appendChild(iframe);
1118
1223
  try {
1119
- const html = await loadHtml(opts.url);
1120
- iframe.srcdoc = injectRuntime(html, opts.url, domains);
1224
+ const html = opts.html ?? await loadHtml(opts.url);
1225
+ iframe.srcdoc = injectRuntime(html, opts.url, domains, opts.runtimePreludeSource);
1226
+ (doc.body ?? doc.documentElement).appendChild(iframe);
1121
1227
  } catch (error) {
1122
1228
  bootError = error instanceof Error ? error.message : String(error);
1123
1229
  }
@@ -1135,16 +1241,18 @@ var NappletConformanceHostBundle = (() => {
1135
1241
  iframe.remove();
1136
1242
  } catch {
1137
1243
  }
1138
- return { installedGlobal: bootError === null, bootError, emitted: [...shell.records] };
1244
+ return { installedGlobal, bootError, emitted: [...shell.records] };
1139
1245
  }
1140
1246
  async function bootAndCollect(options) {
1141
1247
  const doc = options.document ?? document;
1142
1248
  const win = options.window ?? window;
1143
1249
  const resolved = {
1144
1250
  url: options.url,
1251
+ html: options.html,
1145
1252
  readyTimeoutMs: options.readyTimeoutMs ?? 5e3,
1146
1253
  settleMs: options.settleMs ?? 600,
1147
- allowSameOrigin: options.allowSameOrigin ?? false
1254
+ allowSameOrigin: options.allowSameOrigin ?? false,
1255
+ runtimePreludeSource: options.runtimePreludeSource
1148
1256
  };
1149
1257
  const primary = await bootOnce(NAP_DOMAINS, resolved, doc, win);
1150
1258
  let degraded = null;