@crewhaus/hangar-ui 0.6.0 → 0.7.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/assets/js/dom.js CHANGED
@@ -142,21 +142,64 @@ export function toast(message, kind = "error") {
142
142
  return node;
143
143
  }
144
144
 
145
- /** Click-to-copy button (clipboard API; silently no-ops when unavailable). */
146
- export function copyBtn(value, label = "copy") {
147
- const btn = el("button", { class: "btn btn-ghost btn-copy", type: "button", text: label });
148
- btn.addEventListener("click", async () => {
145
+ /**
146
+ * Copy `text`, falling back to the old selection trick.
147
+ *
148
+ * `navigator.clipboard` is a secure-context API. `127.0.0.1` counts as
149
+ * secure, so on the loopback console it always works — and over
150
+ * `http://192.168.x.x`, which is what `crewhaus hangar --lan` serves, the
151
+ * whole object is `undefined` and every copy button in the console reads
152
+ * "copy failed". That is invisible until someone opens the console from a
153
+ * phone, which is exactly who cannot retype a harness directory by hand.
154
+ *
155
+ * The fallback is `document.execCommand("copy")` over a throwaway textarea:
156
+ * deprecated, still implemented everywhere, and not gated on a secure
157
+ * context. The textarea is positioned off-screen rather than hidden because
158
+ * a `display: none` element cannot hold a selection.
159
+ */
160
+ async function writeClipboard(text) {
161
+ if (navigator.clipboard !== undefined) {
149
162
  try {
150
- await navigator.clipboard.writeText(value);
151
- btn.textContent = "copied";
152
- btn.classList.add("ok");
153
- setTimeout(() => {
154
- btn.textContent = label;
155
- btn.classList.remove("ok");
156
- }, 1200);
163
+ await navigator.clipboard.writeText(text);
164
+ return true;
157
165
  } catch {
158
- btn.textContent = "copy failed";
166
+ // Permission refused, or a non-secure context that still exposes the
167
+ // object. Fall through rather than giving up.
159
168
  }
169
+ }
170
+ const scratch = el("textarea");
171
+ scratch.value = text;
172
+ scratch.setAttribute("readonly", "readonly");
173
+ scratch.setAttribute("aria-hidden", "true");
174
+ // Off-screen rather than hidden: a `display: none` element cannot hold a
175
+ // selection, and `opacity: 0` alone would still scroll the page.
176
+ scratch.style.position = "fixed";
177
+ scratch.style.top = "-1000px";
178
+ scratch.style.opacity = "0";
179
+ document.body.appendChild(scratch);
180
+ try {
181
+ scratch.select();
182
+ scratch.setSelectionRange(0, text.length);
183
+ return document.execCommand("copy");
184
+ } catch {
185
+ return false;
186
+ } finally {
187
+ scratch.remove();
188
+ }
189
+ }
190
+
191
+ /** Click-to-copy button. Works on the loopback console and over a LAN
192
+ * address alike — see {@link writeClipboard}. */
193
+ export function copyBtn(value, label = "copy") {
194
+ const btn = el("button", { class: "btn btn-ghost btn-copy", type: "button", text: label });
195
+ btn.addEventListener("click", async () => {
196
+ const copied = await writeClipboard(value);
197
+ btn.textContent = copied ? "copied" : "copy failed";
198
+ if (copied) btn.classList.add("ok");
199
+ setTimeout(() => {
200
+ btn.textContent = label;
201
+ btn.classList.remove("ok");
202
+ }, 1200);
160
203
  });
161
204
  return btn;
162
205
  }
@@ -28,6 +28,51 @@ import { api } from "../api.js";
28
28
  import { clear, collapsible, dot, el, jsonPre, numberedCode, skeleton, toast } from "../dom.js";
29
29
  import { m3, m3Card, m3Empty } from "./creds.js";
30
30
 
31
+ /**
32
+ * True when `hostname` names this machine — the browser-side half of the
33
+ * CLI's `isLoopbackHost`, deliberately re-implemented rather than imported:
34
+ * the console's JS is hand-written browser ES modules with no build step and
35
+ * no imports out of `assets/`.
36
+ *
37
+ * `location.hostname` hands IPv6 literals over WITHOUT their brackets, so
38
+ * `::1` arrives bare; both spellings are accepted. An empty hostname (a
39
+ * `file://` document) is NOT loopback — falling through to the honest note
40
+ * is the safe direction to be wrong in.
41
+ */
42
+ export function isLoopbackHostname(hostname) {
43
+ const host = typeof hostname === "string" ? hostname.trim().toLowerCase() : "";
44
+ if (host === "") return false;
45
+ const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
46
+ if (bare === "localhost") return true;
47
+ if (bare === "::1" || bare === "0:0:0:0:0:0:0:1") return true;
48
+ // The whole of 127.0.0.0/8 — a daemon on 127.0.0.2 is every bit as local.
49
+ const octets = bare.split(".");
50
+ if (octets.length !== 4) return false;
51
+ if (!octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)) return false;
52
+ return octets[0] === "127";
53
+ }
54
+
55
+ /**
56
+ * Whether to OFFER the daemon's dashboard as a link, or just name it.
57
+ *
58
+ * The daemon's control-UI port is loopback by design — `service-setup`
59
+ * never tunnels it and it carries no auth — so the address the server sends
60
+ * is only usable by a browser sitting at that machine. Since
61
+ * `crewhaus hangar --lan` puts this console on a phone, "usable" is now a
62
+ * question with two answers, and only the browser can tell which: it knows
63
+ * the host it reached the console on.
64
+ *
65
+ * A link that quietly goes nowhere is worse than a line of text that says
66
+ * where the thing is, so a remote viewer gets the address and the reason
67
+ * instead of a dead anchor.
68
+ */
69
+ export function dashboardReach(dashboardUrl, viewerHostname) {
70
+ if (typeof dashboardUrl !== "string" || dashboardUrl === "") return null;
71
+ return isLoopbackHostname(viewerHostname)
72
+ ? { reachable: true, url: dashboardUrl }
73
+ : { reachable: false, url: dashboardUrl };
74
+ }
75
+
31
76
  export async function renderChannels(root, ctx) {
32
77
  clear(root).appendChild(skeleton(5));
33
78
  const [channelsAnswer, gatewayAnswer] = await Promise.all([
@@ -431,16 +476,28 @@ function gatewayCard(body) {
431
476
  : null,
432
477
  ]),
433
478
  ];
434
- if (typeof body.dashboardUrl === "string") {
479
+ const reach = dashboardReach(body.dashboardUrl, window.location.hostname);
480
+ if (reach !== null) {
435
481
  nodes.push(
436
- el("p", null, [
437
- el("a", {
438
- href: body.dashboardUrl,
439
- target: "_blank",
440
- rel: "noreferrer",
441
- text: "open the daemon's own dashboard",
442
- }),
443
- ]),
482
+ el(
483
+ "p",
484
+ null,
485
+ reach.reachable
486
+ ? [
487
+ el("a", {
488
+ href: reach.url,
489
+ target: "_blank",
490
+ rel: "noreferrer",
491
+ text: "open the daemon's own dashboard",
492
+ }),
493
+ ]
494
+ : [
495
+ el("span", {
496
+ class: "muted",
497
+ text: `the daemon's own dashboard is at ${reach.url} — it binds loopback and is not tunnelled, so it opens only from the machine running the daemon, not from here`,
498
+ }),
499
+ ],
500
+ ),
444
501
  );
445
502
  }
446
503
  if (body.status !== null && body.status !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/hangar-ui",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "The Hangar web console's static UI — zero-build, dependency-free browser ES modules embedded as text assets for the single-binary CLI to hand to the hangar server",
6
6
  "main": "dist/index.js",