@bulolo/hermes-link 0.3.4 → 0.3.6

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.
@@ -1546,9 +1546,9 @@ async function discoverRouteCandidates(options) {
1546
1546
  const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
1547
1547
  const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
1548
1548
  const preferredUrls = [
1549
+ ...lanIps.map((ip) => buildDirectUrl(ip, options.port)),
1549
1550
  ...publicIpv4s.map((ip) => buildDirectUrl(ip, options.port)),
1550
- ...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port)),
1551
- ...lanIps.map((ip) => buildDirectUrl(ip, options.port))
1551
+ ...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port))
1552
1552
  ];
1553
1553
  return { lanIps, publicIpv4s, publicIpv6s, preferredUrls, environment };
1554
1554
  }
@@ -8885,10 +8885,29 @@ async function startLinkService(options) {
8885
8885
  });
8886
8886
  const rootRouter = new Router13();
8887
8887
  rootRouter.get("/pair", async (ctx) => {
8888
- const connectToken = typeof ctx.query.connect_token === "string" ? ctx.query.connect_token : "";
8889
- const sessionId = typeof ctx.query.session_id === "string" ? ctx.query.session_id : connectToken ? `ps_${connectToken.slice(0, 16)}` : "";
8890
- ctx.type = "text/html";
8891
- ctx.body = await buildPairingPage({ port: config.port, connectToken, sessionId, identity });
8888
+ const sessionId = typeof ctx.query.session_id === "string" ? ctx.query.session_id : "";
8889
+ if (!sessionId) {
8890
+ ctx.status = 400;
8891
+ ctx.type = "text/plain";
8892
+ ctx.body = "Missing session_id";
8893
+ return;
8894
+ }
8895
+ const session = await readPairingSession(sessionId, paths);
8896
+ if (!session) {
8897
+ ctx.status = 404;
8898
+ ctx.type = "text/plain";
8899
+ ctx.body = "Pairing session not found";
8900
+ return;
8901
+ }
8902
+ const claimed = await isPairingSessionClaimed(sessionId, paths);
8903
+ ctx.set("content-type", "text/html; charset=utf-8");
8904
+ ctx.set("cache-control", "no-store");
8905
+ ctx.body = await renderPairingPage({
8906
+ session,
8907
+ claimed,
8908
+ version: LINK_VERSION,
8909
+ linkId: identity.link_id ?? session.link_id
8910
+ });
8892
8911
  });
8893
8912
  rootRouter.get("/api/v1/status", async (ctx) => {
8894
8913
  await authenticateRequest(ctx, paths);
@@ -8980,151 +8999,300 @@ async function startLinkService(options) {
8980
8999
  };
8981
9000
  return { app, server, stop };
8982
9001
  }
8983
- async function buildPairingPage(options) {
8984
- const { port, connectToken, sessionId, identity } = options;
8985
- const qrPayload = connectToken ? JSON.stringify({
9002
+ function escapeHtml(s) {
9003
+ return s.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;").replace(/"/gu, "&quot;");
9004
+ }
9005
+ function formatDate(iso, locale = "zh-CN") {
9006
+ const d = new Date(iso);
9007
+ if (!Number.isFinite(d.getTime())) return iso;
9008
+ return d.toLocaleString(locale, { hour12: locale !== "zh-CN" });
9009
+ }
9010
+ async function renderPairingPage(options) {
9011
+ const { session, claimed, version, linkId } = options;
9012
+ const isExpired = !claimed && isPairingSessionExpired(session);
9013
+ const qrPayload = JSON.stringify({
8986
9014
  kind: "hermes_link_pairing",
8987
9015
  version: 1,
8988
- link_id: identity.link_id ?? "",
8989
- display_name: "Hermes Link",
8990
- session_id: sessionId,
8991
- code: connectToken,
8992
- preferred_urls: [`http://127.0.0.1:${port}`]
8993
- }) : "";
8994
- let qrHtml = "";
8995
- if (qrPayload) {
8996
- try {
8997
- const qrSvg = await QRCode.toString(qrPayload, {
8998
- type: "svg",
8999
- margin: 1,
9000
- width: 240,
9001
- errorCorrectionLevel: "M"
9002
- });
9003
- qrHtml = `<div class="qr">${qrSvg}</div>`;
9004
- } catch {
9005
- qrHtml = "";
9006
- }
9007
- }
9008
- const baseUrl = `http://127.0.0.1:${port}`;
9009
- return `<!DOCTYPE html>
9016
+ link_id: session.link_id,
9017
+ display_name: session.display_name,
9018
+ session_id: session.session_id,
9019
+ code: session.code,
9020
+ preferred_urls: session.preferred_urls
9021
+ });
9022
+ const qrSvg = await QRCode.toString(qrPayload, { type: "svg", margin: 1, width: 320, errorCorrectionLevel: "M" });
9023
+ const qrDataUri = `data:image/svg+xml;base64,${Buffer.from(qrSvg).toString("base64")}`;
9024
+ const currentUrl = session.local_api_url.replace(/\/+$/u, "");
9025
+ const expiresAtMs = Date.parse(session.expires_at);
9026
+ const initialState = claimed ? "claimed" : isExpired ? "expired" : "waiting";
9027
+ return `<!doctype html>
9010
9028
  <html lang="en">
9011
9029
  <head>
9012
- <meta charset="UTF-8" />
9013
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
9014
- <title>Hermes Link \u2014 Pairing</title>
9030
+ <meta charset="utf-8" />
9031
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
9032
+ <meta name="color-scheme" content="light dark" />
9033
+ <title>Hermes Link Pairing</title>
9015
9034
  <style>
9016
- * { box-sizing: border-box; margin: 0; padding: 0; }
9017
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f0f0f; color: #e5e5e5; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 1rem; }
9018
- .card { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 12px; padding: 2rem; max-width: 520px; width: 100%; }
9019
- h1 { font-size: 1.2rem; font-weight: 600; margin-bottom: 0.25rem; text-align: center; }
9020
- .subtitle { color: #6b7280; font-size: 0.85rem; text-align: center; margin-bottom: 1.5rem; }
9021
- .qr { display: flex; justify-content: center; margin-bottom: 1.5rem; }
9022
- .qr svg { width: 200px; height: 200px; background: #fff; border-radius: 8px; padding: 8px; }
9023
- .section { margin-bottom: 1.25rem; }
9024
- .label { font-size: 0.75rem; color: #6b7280; margin-bottom: 0.35rem; text-transform: uppercase; letter-spacing: 0.05em; }
9025
- .mono { font-family: monospace; background: #0f0f0f; border: 1px solid #2a2a2a; border-radius: 6px; padding: 0.6rem 0.75rem; font-size: 0.78rem; word-break: break-all; color: #7dd3fc; cursor: pointer; user-select: all; }
9026
- .mono:hover { border-color: #3b82f6; }
9027
- .divider { border: none; border-top: 1px solid #2a2a2a; margin: 1.5rem 0; }
9028
- button { background: #3b82f6; color: #fff; border: none; border-radius: 8px; padding: 0.7rem 1.5rem; font-size: 0.9rem; cursor: pointer; width: 100%; }
9029
- button:hover { background: #2563eb; }
9030
- button:disabled { background: #374151; cursor: not-allowed; color: #6b7280; }
9031
- .status { margin-top: 0.75rem; font-size: 0.85rem; padding: 0.5rem 0.75rem; border-radius: 6px; display: none; text-align: center; }
9032
- .status.success { color: #6ee7b7; background: #064e3b22; display: block; }
9033
- .status.error { color: #fca5a5; background: #7f1d1d22; display: block; }
9034
- .result-row { margin-top: 0.5rem; }
9035
- .tag { display: inline-block; font-size: 0.7rem; background: #1e3a5f; color: #93c5fd; border-radius: 4px; padding: 0.1rem 0.4rem; margin-bottom: 0.25rem; }
9035
+ :root {
9036
+ color-scheme: light dark;
9037
+ --bg: #f4f5f7;
9038
+ --panel: rgba(255,255,255,0.78);
9039
+ --panel-strong: rgba(255,255,255,0.94);
9040
+ --text: #151922;
9041
+ --muted: #5f6673;
9042
+ --line: rgba(21,25,34,0.12);
9043
+ --accent: #2d5cff;
9044
+ --accent-soft: rgba(45,92,255,0.12);
9045
+ --good: #0b8457;
9046
+ --shadow: 0 24px 90px rgba(18,24,38,0.12);
9047
+ }
9048
+ @media (prefers-color-scheme: dark) {
9049
+ :root {
9050
+ --bg: #0c1017;
9051
+ --panel: rgba(16,20,28,0.78);
9052
+ --panel-strong: rgba(16,20,28,0.94);
9053
+ --text: #eef2f8;
9054
+ --muted: #9ba4b3;
9055
+ --line: rgba(255,255,255,0.12);
9056
+ --accent: #8ab4ff;
9057
+ --accent-soft: rgba(138,180,255,0.12);
9058
+ --good: #67d7a7;
9059
+ --shadow: 0 24px 90px rgba(0,0,0,0.45);
9060
+ }
9061
+ }
9062
+ * { box-sizing: border-box; }
9063
+ body { margin: 0; min-height: 100vh; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--text); background: linear-gradient(180deg, var(--bg) 0%, color-mix(in srgb, var(--bg) 88%, var(--accent) 12%) 100%); }
9064
+ .shell { min-height: 100vh; display: grid; place-items: center; padding: 28px 18px; }
9065
+ .panel { width: min(1040px, 100%); border: 1px solid var(--line); border-radius: 28px; background: var(--panel); box-shadow: var(--shadow); backdrop-filter: blur(18px); overflow: hidden; }
9066
+ .hero { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(320px, 390px); gap: 0; }
9067
+ .copy { padding: 34px 34px 30px; border-right: 1px solid var(--line); }
9068
+ .header-row { display: flex; justify-content: space-between; align-items: center; }
9069
+ .eyebrow { display: inline-flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-size: 13px; font-weight: 600; }
9070
+ .lang-btn { background: var(--accent-soft); color: var(--accent); border: none; border-radius: 999px; padding: 6px 14px; font-size: 12px; font-weight: 600; cursor: pointer; font-family: inherit; line-height: 1; }
9071
+ .lang-btn:hover { opacity: 0.75; }
9072
+ h1 { margin: 18px 0 12px; font-size: clamp(34px, 4vw, 52px); line-height: 1.02; }
9073
+ .subtitle { max-width: 42ch; margin: 0; color: var(--muted); font-size: 16px; line-height: 1.7; }
9074
+ .meta-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 26px; }
9075
+ .meta { padding: 16px 16px 15px; border-radius: 18px; background: var(--panel-strong); border: 1px solid var(--line); }
9076
+ .meta-label { display: block; color: var(--muted); font-size: 12px; line-height: 1.4; margin-bottom: 8px; }
9077
+ .meta-value { font-size: 15px; line-height: 1.5; word-break: break-word; }
9078
+ .steps { display: grid; gap: 10px; margin-top: 18px; }
9079
+ .step { display: flex; gap: 12px; align-items: flex-start; padding: 14px 16px; border: 1px solid var(--line); border-radius: 18px; background: var(--panel-strong); }
9080
+ .step-badge { flex: none; width: 26px; height: 26px; border-radius: 999px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent); font-size: 13px; font-weight: 600; }
9081
+ .step-title { font-size: 14px; line-height: 1.45; margin: 0; font-weight: 600; }
9082
+ .step-copy { margin: 3px 0 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
9083
+ .hint { margin: 10px 0 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
9084
+ .qr { padding: 26px 26px 30px; background: linear-gradient(180deg, rgba(255,255,255,0.16), rgba(255,255,255,0)); }
9085
+ .card { border: 1px solid var(--line); border-radius: 24px; background: var(--panel-strong); padding: 20px; }
9086
+ .status { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 18px; }
9087
+ .status-title { margin: 0; font-size: 18px; line-height: 1.3; }
9088
+ .pill { display: inline-flex; align-items: center; justify-content: center; padding: 7px 11px; border-radius: 999px; background: rgba(11,132,87,0.12); color: var(--good); font-size: 12px; font-weight: 600; white-space: nowrap; }
9089
+ .qr-frame { display: grid; place-items: center; padding: 18px; border-radius: 24px; background: linear-gradient(180deg, rgba(45,92,255,0.06), rgba(45,92,255,0)); border: 1px solid var(--line); }
9090
+ .qr-frame img { width: min(100%, 300px); aspect-ratio: 1; display: block; border-radius: 18px; background: #fff; padding: 14px; }
9091
+ .manual { margin-top: 16px; border: 1px solid var(--line); border-radius: 18px; overflow: hidden; }
9092
+ .manual-row { display: flex; flex-direction: column; gap: 2px; padding: 12px 16px; background: rgba(0,0,0,0.03); }
9093
+ .manual-row + .manual-row { border-top: 1px solid var(--line); }
9094
+ .manual-label { font-size: 11px; color: var(--muted); letter-spacing: 0.04em; }
9095
+ .manual-value { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 14px; word-break: break-all; user-select: all; }
9096
+ .manual-value.code { font-size: 20px; letter-spacing: 0.16em; text-align: center; }
9097
+ .footer { display: flex; justify-content: space-between; gap: 18px; flex-wrap: wrap; padding-top: 16px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9098
+ @media (max-width: 920px) { .hero { grid-template-columns: 1fr; } .copy { border-right: none; border-bottom: 1px solid var(--line); } }
9099
+ @media (max-width: 640px) { .copy, .qr { padding: 22px 18px; } .meta-grid { grid-template-columns: 1fr; } .status { align-items: flex-start; flex-direction: column; } .code { font-size: 18px; letter-spacing: 0.14em; } }
9036
9100
  </style>
9037
9101
  </head>
9038
9102
  <body>
9039
- <div class="card">
9040
- <h1>Hermes Link Pairing</h1>
9041
- <p class="subtitle">\u7AEF\u53E3 ${port} \xB7 ${identity.link_id ?? "\u672A\u5206\u914D"}</p>
9103
+ <main class="shell">
9104
+ <section class="panel">
9105
+ <div class="hero">
9106
+ <div class="copy">
9107
+ <div class="header-row">
9108
+ <span class="eyebrow">Hermes Link \xB7 ${escapeHtml(version)}</span>
9109
+ <button id="langToggle" class="lang-btn">\u4E2D\u6587</button>
9110
+ </div>
9111
+ <h1 data-i18n="h1">Complete Pairing in the App</h1>
9112
+ <p class="subtitle" data-i18n="subtitle">Scan the QR code or enter the connect token to link this device.</p>
9113
+ <div class="meta-grid">
9114
+ <div class="meta">
9115
+ <span class="meta-label" data-i18n="metaLocalUrl">Local Address</span>
9116
+ <div class="meta-value">${escapeHtml(currentUrl)}</div>
9117
+ </div>
9118
+ <div class="meta">
9119
+ <span class="meta-label">Link ID</span>
9120
+ <div class="meta-value">${escapeHtml(linkId)}</div>
9121
+ </div>
9122
+ <div class="meta">
9123
+ <span class="meta-label" data-i18n="metaConnectToken">Connect Token</span>
9124
+ <div class="meta-value">${escapeHtml(session.code)}</div>
9125
+ </div>
9126
+ <div class="meta">
9127
+ <span class="meta-label" data-i18n="metaExpires">Expires</span>
9128
+ <div class="meta-value" id="expiresValue" data-iso="${escapeHtml(session.expires_at)}">${escapeHtml(formatDate(session.expires_at, "en-US"))}</div>
9129
+ </div>
9130
+ </div>
9131
+ <div class="steps">
9132
+ <div class="step">
9133
+ <div class="step-badge">1</div>
9134
+ <div>
9135
+ <p class="step-title" data-i18n="step1Title">Open &ldquo;Connect Hermes Link&rdquo; in the App</p>
9136
+ <p class="step-copy" data-i18n="step1Copy">Find the &ldquo;Connect Link&rdquo; entry in the App, then scan the QR code or enter the token manually. The App will switch to this Link automatically after pairing.</p>
9137
+ </div>
9138
+ </div>
9139
+ <div class="step">
9140
+ <div class="step-badge">2</div>
9141
+ <div>
9142
+ <p class="step-title" data-i18n="step2Title">Scan the QR code, or enter the address and token manually</p>
9143
+ <p class="step-copy" data-i18n="step2Copy">If scanning is inconvenient, enter the address and connect token below in the App.</p>
9144
+ </div>
9145
+ </div>
9146
+ <div class="step">
9147
+ <div class="step-badge">3</div>
9148
+ <div>
9149
+ <p class="step-title" data-i18n="step3Title">This page will update automatically once pairing is complete</p>
9150
+ <p class="step-copy" id="statusHint">${initialState === "claimed" ? "Pairing complete. You can close this page." : initialState === "expired" ? "This QR code has expired. Please run hermeslink pair again." : "Open the App to scan, or copy the connect token for manual entry."}</p>
9151
+ </div>
9152
+ </div>
9153
+ </div>
9154
+ <p class="hint" data-i18n="hint">You can keep this page open to check pairing status later. If pairing has already succeeded, the page will not prompt for a re-scan.</p>
9155
+ </div>
9156
+ <div class="qr">
9157
+ <div class="card">
9158
+ <div class="status">
9159
+ <h2 class="status-title" id="statusTitle">${initialState === "claimed" ? "Pairing Complete" : initialState === "expired" ? "Pairing Expired" : "Waiting for App to Scan"}</h2>
9160
+ <span class="pill" id="statusPill">${initialState === "claimed" ? "Scanned" : initialState === "expired" ? "Expired" : "Waiting"}</span>
9161
+ </div>
9162
+ <div class="qr-frame">
9163
+ <img src="${qrDataUri}" alt="Hermes Link pairing QR code" />
9164
+ </div>
9165
+ <div class="manual">
9166
+ <div class="manual-row">
9167
+ <span class="manual-label" data-i18n="manualAddrLabel">Address (pick any that works)</span>
9168
+ ${session.preferred_urls.map((u) => `<span class="manual-value">${escapeHtml(u)}</span>`).join("\n ")}
9169
+ </div>
9170
+ <div class="manual-row">
9171
+ <span class="manual-label" data-i18n="manualTokenLabel">Connect Token</span>
9172
+ <span class="manual-value code">${escapeHtml(session.code)}</span>
9173
+ </div>
9174
+ </div>
9175
+ </div>
9176
+ </div>
9177
+ </div>
9178
+ </section>
9179
+ </main>
9180
+ <script>
9181
+ const sessionId = ${JSON.stringify(session.session_id)};
9182
+ const expiresAtMs = ${Number.isFinite(expiresAtMs) ? String(expiresAtMs) : "Number.NaN"};
9183
+ const initialClaimed = ${JSON.stringify(claimed)};
9042
9184
 
9043
- ${qrHtml}
9185
+ const T = {
9186
+ zh: {
9187
+ h1: '\u5728 App \u91CC\u5B8C\u6210\u8FD9\u6B21\u914D\u5BF9',
9188
+ subtitle: '\u626B\u7801\u6216\u624B\u52A8\u8F93\u5165\u914D\u5BF9\u7801\uFF0C\u5B8C\u6210 App \u4E0E\u672C\u673A\u7684\u8FDE\u63A5\u3002',
9189
+ metaLocalUrl: '\u672C\u5730\u5730\u5740',
9190
+ metaConnectToken: '\u914D\u5BF9\u7801',
9191
+ metaExpires: '\u8FC7\u671F\u65F6\u95F4',
9192
+ step1Title: '\u5728 App \u91CC\u6253\u5F00\u201C\u8FDE\u63A5 Hermes Link\u201D',
9193
+ step1Copy: '\u5728 App \u91CC\u627E\u5230\u201C\u8FDE\u63A5 Link\u201D\u5165\u53E3\uFF0C\u9009\u62E9\u626B\u7801\u6216\u624B\u52A8\u8F93\u5165\u914D\u5BF9\u7801\u3002\u914D\u5BF9\u6210\u529F\u540E\uFF0CApp \u4F1A\u81EA\u52A8\u5207\u5230\u8FD9\u53F0 Link\u3002',
9194
+ step2Title: '\u626B\u4E8C\u7EF4\u7801\uFF0C\u6216\u624B\u52A8\u586B\u5199\u5730\u5740\u548C\u914D\u5BF9\u7801',
9195
+ step2Copy: '\u5982\u679C\u626B\u7801\u4E0D\u65B9\u4FBF\uFF0C\u53EF\u5728 App \u91CC\u624B\u52A8\u8F93\u5165\u4E0B\u65B9\u7684\u5730\u5740\u548C\u914D\u5BF9\u7801\u5B8C\u6210\u8FDE\u63A5\u3002',
9196
+ step3Title: '\u914D\u5BF9\u6210\u529F\u540E\uFF0C\u8FD9\u4E2A\u9875\u9762\u4F1A\u81EA\u52A8\u53D8\u6210\u5DF2\u5B8C\u6210\u72B6\u6001',
9197
+ hint: '\u53EF\u5728\u7EC8\u7AEF\u7EE7\u7EED\u4FDD\u7559\u8FD9\u4E2A\u9875\u9762\uFF0C\u65B9\u4FBF\u7A0D\u540E\u6838\u5BF9\u72B6\u6001\uFF1B\u5982\u679C\u914D\u5BF9\u5DF2\u7ECF\u6210\u529F\uFF0C\u9875\u9762\u4E0D\u4F1A\u518D\u8981\u6C42\u91CD\u65B0\u626B\u7801\u3002',
9198
+ manualAddrLabel: '\u5730\u5740\uFF08\u4EFB\u9009\u4E00\u4E2A\u53EF\u7528\u7684\uFF09',
9199
+ manualTokenLabel: '\u914D\u5BF9\u7801',
9200
+ status_waiting: '\u7B49\u5F85 App \u626B\u7801',
9201
+ status_claimed: '\u5DF2\u5B8C\u6210\u914D\u5BF9',
9202
+ status_expired: '\u914D\u5BF9\u5DF2\u8FC7\u671F',
9203
+ pill_waiting: '\u7B49\u5F85\u4E2D',
9204
+ pill_claimed: '\u5DF2\u626B\u7801',
9205
+ pill_expired: '\u5DF2\u8FC7\u671F',
9206
+ hint_waiting: '\u6253\u5F00 App \u626B\u7801\uFF0C\u6216\u8005\u590D\u5236\u914D\u5BF9\u7801\u624B\u52A8\u8F93\u5165\u3002',
9207
+ hint_claimed: 'App \u5DF2\u5B8C\u6210\u914D\u5BF9\uFF0C\u8FD9\u4E2A\u9875\u9762\u53EF\u4EE5\u5173\u95ED\u3002',
9208
+ hint_expired: '\u8FD9\u6B21\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u8FD0\u884C hermeslink pair\u3002',
9209
+ expires_locale: 'zh-CN',
9210
+ langToggle: 'EN',
9211
+ },
9212
+ en: {
9213
+ h1: 'Complete Pairing in the App',
9214
+ subtitle: 'Scan the QR code or enter the connect token to link this device.',
9215
+ metaLocalUrl: 'Local Address',
9216
+ metaConnectToken: 'Connect Token',
9217
+ metaExpires: 'Expires',
9218
+ step1Title: 'Open \u201CConnect Hermes Link\u201D in the App',
9219
+ step1Copy: 'Find the \u201CConnect Link\u201D entry in the App, then scan the QR code or enter the token manually. The App will switch to this Link automatically after pairing.',
9220
+ step2Title: 'Scan the QR code, or enter the address and token manually',
9221
+ step2Copy: 'If scanning is inconvenient, enter the address and connect token below in the App.',
9222
+ step3Title: 'This page will update automatically once pairing is complete',
9223
+ hint: 'You can keep this page open to check pairing status later. If pairing has already succeeded, the page will not prompt for a re-scan.',
9224
+ manualAddrLabel: 'Address (pick any that works)',
9225
+ manualTokenLabel: 'Connect Token',
9226
+ status_waiting: 'Waiting for App to Scan',
9227
+ status_claimed: 'Pairing Complete',
9228
+ status_expired: 'Pairing Expired',
9229
+ pill_waiting: 'Waiting',
9230
+ pill_claimed: 'Scanned',
9231
+ pill_expired: 'Expired',
9232
+ hint_waiting: 'Open the App to scan, or copy the connect token for manual entry.',
9233
+ hint_claimed: 'Pairing complete. You can close this page.',
9234
+ hint_expired: 'This QR code has expired. Please run hermeslink pair again.',
9235
+ expires_locale: 'en-US',
9236
+ langToggle: '\u4E2D\u6587',
9237
+ },
9238
+ };
9044
9239
 
9045
- <div class="section">
9046
- <div class="label">App \u626B\u7801\u5185\u5BB9\uFF08JSON\uFF09</div>
9047
- <div class="mono" title="\u70B9\u51FB\u590D\u5236" onclick="copyText(this)">${qrPayload.replace(/</g, "&lt;")}</div>
9048
- </div>
9240
+ let lang = localStorage.getItem('hl-lang') || 'en';
9241
+ let state = ${JSON.stringify(initialState)};
9049
9242
 
9050
- <div class="section">
9051
- <div class="label">Session ID</div>
9052
- <div class="mono" title="\u70B9\u51FB\u590D\u5236" onclick="copyText(this)">${sessionId}</div>
9053
- </div>
9243
+ const statusTitleEl = document.querySelector('#statusTitle');
9244
+ const statusPillEl = document.querySelector('#statusPill');
9245
+ const statusHintEl = document.querySelector('#statusHint');
9246
+ const langToggleEl = document.querySelector('#langToggle');
9247
+ const expiresEl = document.querySelector('#expiresValue');
9054
9248
 
9055
- <div class="section">
9056
- <div class="label">Claim Token\uFF08code\uFF09</div>
9057
- <div class="mono" title="\u70B9\u51FB\u590D\u5236" onclick="copyText(this)">${connectToken}</div>
9058
- </div>
9249
+ function applyLang() {
9250
+ const s = T[lang];
9251
+ document.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN';
9252
+ document.querySelectorAll('[data-i18n]').forEach(el => {
9253
+ const key = el.dataset.i18n;
9254
+ if (s[key] !== undefined) el.textContent = s[key];
9255
+ });
9256
+ statusTitleEl.textContent = s['status_' + state];
9257
+ statusPillEl.textContent = s['pill_' + state];
9258
+ statusHintEl.textContent = s['hint_' + state];
9259
+ if (expiresEl) {
9260
+ const iso = expiresEl.dataset.iso;
9261
+ const d = new Date(iso);
9262
+ expiresEl.textContent = isFinite(d.getTime())
9263
+ ? d.toLocaleString(s.expires_locale, { hour12: lang === 'en' })
9264
+ : iso;
9265
+ }
9266
+ langToggleEl.textContent = s.langToggle;
9267
+ }
9059
9268
 
9060
- <div class="section">
9061
- <div class="label">\u914D\u5BF9\u63A5\u53E3\uFF08App \u8C03\u7528\uFF09</div>
9062
- <div class="mono">POST ${baseUrl}/api/v1/pairing/claim</div>
9063
- </div>
9269
+ langToggleEl.addEventListener('click', () => {
9270
+ lang = lang === 'zh' ? 'en' : 'zh';
9271
+ localStorage.setItem('hl-lang', lang);
9272
+ applyLang();
9273
+ });
9064
9274
 
9065
- <hr class="divider" />
9275
+ if (lang === 'zh') applyLang();
9066
9276
 
9067
- <div class="label" style="margin-bottom:0.75rem">\u6D4F\u89C8\u5668\u5FEB\u901F\u914D\u5BF9</div>
9068
- <button id="btn" onclick="pairBrowser()">\u5728\u6B64\u8BBE\u5907\u4E0A\u914D\u5BF9</button>
9069
- <div class="status" id="status"></div>
9070
- <div id="results"></div>
9071
- </div>
9277
+ let refreshTimer = null;
9278
+ const stopPolling = () => { if (refreshTimer !== null) { clearInterval(refreshTimer); refreshTimer = null; } };
9072
9279
 
9073
- <script>
9074
- ${sessionId ? `
9075
- let pollTimer = setInterval(async () => {
9076
- try {
9077
- const res = await fetch('/api/v1/pairing/session?session_id=${sessionId}');
9078
- const data = await res.json();
9079
- if (data.ok && data.session?.claimed) {
9080
- clearInterval(pollTimer);
9081
- showStatus('success', 'App \u5DF2\u5B8C\u6210\u914D\u5BF9 \u2713');
9082
- }
9083
- } catch {}
9084
- }, 2000);
9085
- ` : ""}
9280
+ const markClaimed = () => { state = 'claimed'; applyLang(); stopPolling(); };
9281
+ const markExpired = () => { state = 'expired'; applyLang(); stopPolling(); };
9086
9282
 
9087
- async function pairBrowser() {
9088
- const btn = document.getElementById('btn');
9089
- btn.disabled = true;
9090
- btn.textContent = '\u914D\u5BF9\u4E2D...';
9283
+ const refresh = async () => {
9284
+ if (Number.isFinite(expiresAtMs) && Date.now() >= expiresAtMs) { markExpired(); return; }
9091
9285
  try {
9092
- const res = await fetch('/api/v1/auth/device-session', {
9093
- method: 'POST',
9094
- headers: { 'Authorization': 'Bearer ${connectToken}', 'Content-Type': 'application/json' },
9095
- body: JSON.stringify({ device_label: navigator.userAgent.slice(0, 64), device_platform: 'web' })
9096
- });
9097
- const data = await res.json();
9098
- if (res.ok && data.access_token) {
9099
- btn.textContent = '\u5DF2\u914D\u5BF9';
9100
- showStatus('success', '\u914D\u5BF9\u6210\u529F\uFF01');
9101
- const results = document.getElementById('results');
9102
- results.innerHTML = \`
9103
- <div class="result-row"><span class="tag">access_token \xB7 2h</span><div class="mono" onclick="copyText(this)">\${data.access_token.token}</div></div>
9104
- <div class="result-row" style="margin-top:0.5rem"><span class="tag">refresh_token \xB7 90days</span><div class="mono" onclick="copyText(this)">\${data.refresh_token.token}</div></div>
9105
- \`;
9106
- } else {
9107
- throw new Error(data.error?.message || JSON.stringify(data));
9108
- }
9109
- } catch (e) {
9110
- btn.disabled = false;
9111
- btn.textContent = '\u5728\u6B64\u8BBE\u5907\u4E0A\u914D\u5BF9';
9112
- showStatus('error', '\u914D\u5BF9\u5931\u8D25: ' + e.message);
9113
- }
9114
- }
9115
-
9116
- function showStatus(type, msg) {
9117
- const el = document.getElementById('status');
9118
- el.className = 'status ' + type;
9119
- el.textContent = msg;
9120
- }
9286
+ const response = await fetch('/api/v1/pairing/session?session_id=' + encodeURIComponent(sessionId), { headers: { accept: 'application/json' } });
9287
+ if (response.status === 404) { markExpired(); return; }
9288
+ if (!response.ok) return;
9289
+ const payload = await response.json();
9290
+ if (payload?.session?.claimed) markClaimed();
9291
+ } catch {}
9292
+ };
9121
9293
 
9122
- function copyText(el) {
9123
- navigator.clipboard.writeText(el.textContent.trim()).then(() => {
9124
- const orig = el.style.borderColor;
9125
- el.style.borderColor = '#10b981';
9126
- setTimeout(() => el.style.borderColor = orig, 800);
9127
- }).catch(() => {});
9294
+ if (!initialClaimed) {
9295
+ refreshTimer = setInterval(refresh, 2000);
9128
9296
  }
9129
9297
  </script>
9130
9298
  </body>
package/dist/cli/index.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  saveConfig,
22
22
  startLinkService,
23
23
  writeJsonFile
24
- } from "../chunk-ELQBIHDQ.js";
24
+ } from "../chunk-7IVSOP5F.js";
25
25
  import "../chunk-NP3Y2NVF.js";
26
26
 
27
27
  // src/cli/index.ts
@@ -259,7 +259,10 @@ async function runPairingPreflight(options) {
259
259
  display_name: "Hermes Link",
260
260
  session_id: sessionId,
261
261
  code: token.token,
262
- preferred_urls: preferredUrls
262
+ preferred_urls: preferredUrls,
263
+ lan_ips: routes?.lanIps ?? [],
264
+ public_ipv4s: routes?.publicIpv4s ?? [],
265
+ public_ipv6s: routes?.publicIpv6s ?? []
263
266
  };
264
267
  const pageUrl = buildLocalPairingPageUrl(preferredUrls[0] ?? `http://127.0.0.1:${options.config.port}`, sessionId, token.token);
265
268
  if (options.openBrowser !== false) {
@@ -273,8 +276,8 @@ async function runPairingPreflight(options) {
273
276
  preferredUrls
274
277
  };
275
278
  }
276
- function buildLocalPairingPageUrl(baseUrl, sessionId, connectToken) {
277
- const qs = new URLSearchParams({ session_id: sessionId, connect_token: connectToken });
279
+ function buildLocalPairingPageUrl(baseUrl, sessionId, _connectToken) {
280
+ const qs = new URLSearchParams({ session_id: sessionId });
278
281
  return `${baseUrl}/pair?${qs.toString()}`;
279
282
  }
280
283
 
@@ -414,14 +417,16 @@ async function cmdPair(paths) {
414
417
  const result = await runPairingPreflight({ identity, config, paths, openBrowser: false });
415
418
  process.stdout.write("\n");
416
419
  qrcode.generate(result.qrPayload, { small: true });
420
+ const pageUrls = result.preferredUrls.map((base) => buildLocalPairingPageUrl(base, result.sessionId));
421
+ const label = "Pairing page: ";
422
+ const indent = " ".repeat(label.length);
417
423
  process.stdout.write(`
418
- Pairing page: ${result.pageUrl}
424
+ ${label}${pageUrls.join(`
425
+ ${indent}`)}
419
426
  `);
420
427
  process.stdout.write(`Session ID: ${result.sessionId}
421
428
  `);
422
429
  process.stdout.write(`Connect token: ${result.connectToken}
423
- `);
424
- process.stdout.write(`Preferred URLs: ${result.preferredUrls.join(", ")}
425
430
  `);
426
431
  process.stdout.write(`
427
432
  App \u626B\u63CF\u4E8C\u7EF4\u7801\u540E\uFF0C\u8C03\u7528\u4EE5\u4E0B\u63A5\u53E3\u5B8C\u6210\u914D\u5BF9\uFF1A
package/dist/http/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  startLinkService
3
- } from "../chunk-ELQBIHDQ.js";
3
+ } from "../chunk-7IVSOP5F.js";
4
4
  import "../chunk-NP3Y2NVF.js";
5
5
  export {
6
6
  startLinkService
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bulolo/hermes-link",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Provides full client API, multi-device auth and conversation management for Hermes Agent, with LAN and internet connectivity.",
5
5
  "author": "Bulolo",
6
6
  "license": "MIT",
@@ -75,4 +75,4 @@
75
75
  "typescript": "^5.7.2",
76
76
  "vitest": "^2.1.8"
77
77
  }
78
- }
78
+ }