scenri 0.15.1 → 0.16.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/dist/serve.js CHANGED
@@ -5,17 +5,18 @@ import { detectInstallKind } from './chunk-PAIAXRAC.js';
5
5
  export { detectInstallKind } from './chunk-PAIAXRAC.js';
6
6
  import { readMeta, repoSlug } from './chunk-Y3ZPBPLP.js';
7
7
  import { compareSemver, newestStaged } from './chunk-4MAFHYAD.js';
8
+ import { powershellPath, PS_UTF8 } from './chunk-2IGINCPU.js';
8
9
  import { dirname, join, normalize } from 'path';
9
10
  import { fileURLToPath } from 'url';
10
11
  import fs, { existsSync, readFileSync, mkdirSync, createReadStream, rmSync, readdirSync, statSync, renameSync } from 'fs';
11
- import { homedir, networkInterfaces, tmpdir } from 'os';
12
- import { randomBytes, randomUUID, timingSafeEqual, createHash } from 'crypto';
12
+ import { homedir, tmpdir, networkInterfaces } from 'os';
13
13
  import { copyFile, readFile, stat, readdir, mkdtemp, rm, access, rename, unlink, writeFile } from 'fs/promises';
14
- import { spawn } from 'child_process';
14
+ import { spawn, execFile } from 'child_process';
15
15
  import sharp21 from 'sharp';
16
16
  import Fastify from 'fastify';
17
17
  import fastifyStatic from '@fastify/static';
18
18
  import fastifyMultipart from '@fastify/multipart';
19
+ import { randomUUID, randomInt, timingSafeEqual, createHash } from 'crypto';
19
20
  import { Ajv2020 } from 'ajv/dist/2020.js';
20
21
  import addFormats from 'ajv-formats';
21
22
  import * as cheerio from 'cheerio';
@@ -23,6 +24,8 @@ import { lookup } from 'dns/promises';
23
24
  import { isIP } from 'net';
24
25
  import JSZip from 'jszip';
25
26
  import { parse } from 'node-html-parser';
27
+ import { createServer } from 'http';
28
+ import { createSocket } from 'dgram';
26
29
  import pixelmatch from 'pixelmatch';
27
30
  import { PNG } from 'pngjs';
28
31
 
@@ -904,7 +907,7 @@ function createRunner(opts = {}) {
904
907
  const env = buildChildEnv(parentEnv, ignoreEnvKeys());
905
908
  return exe.direct ? spawnImpl(exe.command, args, { stdio, env, ...platform !== "win32" ? { detached: true } : {} }) : spawnImpl([exe.command, ...args.map(winArg)].join(" "), [], { stdio, env, shell: true });
906
909
  };
907
- async function run3(args, signal, io) {
910
+ async function run4(args, signal, io) {
908
911
  const exe = await resolution();
909
912
  const timeoutMs = io?.timeoutMs ?? defaultTimeoutMs;
910
913
  return new Promise((resolve, reject) => {
@@ -1195,7 +1198,7 @@ function createRunner(opts = {}) {
1195
1198
  let result;
1196
1199
  try {
1197
1200
  await withWorkDir(
1198
- (dir) => run3(execArgs(dir), void 0, {
1201
+ (dir) => run4(execArgs(dir), void 0, {
1199
1202
  stdin: CONNECT_PROMPT,
1200
1203
  timeoutMs: connectTimeoutMs,
1201
1204
  label: "connect"
@@ -1236,7 +1239,7 @@ function createRunner(opts = {}) {
1236
1239
  connCache = null;
1237
1240
  cached = null;
1238
1241
  }
1239
- return { run: run3, withWorkDir, probe, invalidateProbe, connect, invalidateConnection, noteConnection };
1242
+ return { run: run4, withWorkDir, probe, invalidateProbe, connect, invalidateConnection, noteConnection };
1240
1243
  }
1241
1244
  var PHOTO_VIEWS = [
1242
1245
  "portrait",
@@ -1513,7 +1516,7 @@ function createCodexSetup(opts = {}) {
1513
1516
  const runner = opts.runner ?? createRunner(opts);
1514
1517
  const installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
1515
1518
  const ignoreEnvKeys = opts.ignoreEnvKeys ?? (() => []);
1516
- function run3(cmd, args, timeoutMs) {
1519
+ function run4(cmd, args, timeoutMs) {
1517
1520
  return new Promise((resolve) => {
1518
1521
  let settled = false;
1519
1522
  let stderr = "";
@@ -1562,7 +1565,7 @@ function createCodexSetup(opts = {}) {
1562
1565
  };
1563
1566
  },
1564
1567
  async install() {
1565
- const res = await run3("npm", ["install", "-g", "@openai/codex"], installTimeoutMs);
1568
+ const res = await run4("npm", ["install", "-g", "@openai/codex"], installTimeoutMs);
1566
1569
  if (res.code === 0) {
1567
1570
  runner.invalidateProbe();
1568
1571
  const avail = await runner.probe();
@@ -1596,7 +1599,7 @@ function createCodexSetup(opts = {}) {
1596
1599
  return { ok: false, fallbackCommand: INSTALL_COMMAND, docsUrl: INSTALL_DOCS_URL, detail };
1597
1600
  },
1598
1601
  async login() {
1599
- const res = await run3("codex", ["login"], installTimeoutMs);
1602
+ const res = await run4("codex", ["login"], installTimeoutMs);
1600
1603
  runner.invalidateProbe();
1601
1604
  if (res.code === 0) return { ok: true };
1602
1605
  const detail = (res.spawnError ?? res.stderr).trim().slice(0, 400) || void 0;
@@ -1969,7 +1972,7 @@ function createDemoEngine(saveImage, opts = {}) {
1969
1972
  );
1970
1973
  return hexes.length ? hexes : ["#4B5563", "#9CA3AF"];
1971
1974
  };
1972
- const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").slice(0, 90);
1975
+ const esc2 = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").slice(0, 90);
1973
1976
  async function render(colors, label, w, h, seed) {
1974
1977
  const c0 = colors[seed % colors.length];
1975
1978
  const c1 = colors[(seed + 1) % colors.length] ?? "#111111";
@@ -1979,7 +1982,7 @@ function createDemoEngine(saveImage, opts = {}) {
1979
1982
  </linearGradient></defs>
1980
1983
  <rect width="100%" height="100%" fill="url(#g)"/>
1981
1984
  <circle cx="${w * (0.25 + 0.5 * (seed * 37 % 100) / 100)}" cy="${h * 0.38}" r="${Math.min(w, h) * 0.18}" fill="#ffffff" opacity="0.25"/>
1982
- <text x="24" y="${h - 48}" font-family="Helvetica, Arial" font-size="${Math.max(14, Math.round(w / 42))}" fill="#ffffff" opacity="0.92">${esc(label)}</text>
1985
+ <text x="24" y="${h - 48}" font-family="Helvetica, Arial" font-size="${Math.max(14, Math.round(w / 42))}" fill="#ffffff" opacity="0.92">${esc2(label)}</text>
1983
1986
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
1984
1987
  </svg>`;
1985
1988
  return sharp21(Buffer.from(svg)).png().toBuffer();
@@ -7555,18 +7558,18 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
7555
7558
  patch3({ stage: "fetching_products", discovered, message: `Reading ${discovered.toLocaleString()} products` });
7556
7559
  }
7557
7560
  }
7558
- const run3 = beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered);
7561
+ const run4 = beginWrite(deps, jobId, brandId, baseUrl, platform, tally2, discovered);
7559
7562
  const pictures = drainPictures(deps, jobId, brandId, tally2, signal, hostOf(baseUrl));
7560
7563
  let pictureErrors;
7561
7564
  let pictureReasons = {};
7562
7565
  const stats = { pages: 0, bytes: 0, refused: 0, reasons: {} };
7563
7566
  try {
7564
- if (bulk) for (const p of bulk) run3.write(p);
7567
+ if (bulk) for (const p of bulk) run4.write(p);
7565
7568
  if (urls.length)
7566
7569
  await fetchProductPages(ctx, urls, {
7567
7570
  concurrency: IMPORT_CONCURRENCY,
7568
7571
  maxBytes: 15e5,
7569
- onEach: run3.write,
7572
+ onEach: run4.write,
7570
7573
  stats,
7571
7574
  // A run that cannot finish still has to end. Generous enough for a
7572
7575
  // 2,200 page catalogue, which measures around sixteen minutes, and
@@ -7599,10 +7602,10 @@ async function runJob(deps, jobId, brandId, url, signal, only) {
7599
7602
  message: stoppedMessage(tally2.upserted),
7600
7603
  finished: true
7601
7604
  });
7602
- core.catalog.setSourceStatus(run3.sourceId, "partial", true);
7605
+ core.catalog.setSourceStatus(run4.sourceId, "partial", true);
7603
7606
  return;
7604
7607
  }
7605
- run3.finish({
7608
+ run4.finish({
7606
7609
  sweep,
7607
7610
  warnings,
7608
7611
  errors: pictureErrors,
@@ -7960,7 +7963,7 @@ async function edgeBarGeometry(buf) {
7960
7963
  mean[i] = m;
7961
7964
  sd[i] = Math.sqrt(v / cross);
7962
7965
  }
7963
- const run3 = (from, dir) => {
7966
+ const run4 = (from, dir) => {
7964
7967
  let n = 0;
7965
7968
  for (let i = from; n < len * 0.2; i += dir, n++) {
7966
7969
  if (sd[i] >= 0.6 || Math.abs(mean[i] - mean[from]) >= 1) break;
@@ -7976,7 +7979,7 @@ async function edgeBarGeometry(buf) {
7976
7979
  const isd = probe.reduce((a, i) => a + sd[i], 0) / probe.length;
7977
7980
  return Math.abs(mean[from] - im) > 2 && isd < 5 ? n : 0;
7978
7981
  };
7979
- return { lo: run3(0, 1), hi: run3(len - 1, -1) };
7982
+ return { lo: run4(0, 1), hi: run4(len - 1, -1) };
7980
7983
  };
7981
7984
  const cols = scan(W, H, (x, y) => data[y * W + x]);
7982
7985
  const rows = scan(H, W, (y, x) => data[y * W + x]);
@@ -8296,9 +8299,682 @@ function cancelSceneBuilds(brandId, sceneId) {
8296
8299
  if (b.brandId === brandId && b.sceneId === sceneId && !b.finished) cancelAssetBuild(b.id);
8297
8300
  }
8298
8301
  }
8299
- var LOOPBACK = ["localhost", "127.0.0.1", "::1"];
8302
+
8303
+ // src/network/codePage.ts
8304
+ var LOCKUP_PATHS = [
8305
+ "M64 18.71V38.8L51.38 51.39V12.62H57.9C61.27 12.62 64 15.35 64 18.72V18.71Z",
8306
+ "M12.62 12.62V51.39H6.1C2.73 51.39 0 48.66 0 45.29V25.2L12.62 12.61V12.62Z",
8307
+ "M51.38 51.38L38.79 64H18.7C15.33 64 12.6 61.27 12.6 57.9V51.38H51.37H51.38Z",
8308
+ "M12.62 12.62L25.2 0H45.29C48.66 0 51.39 2.73 51.39 6.1V12.62H12.62Z",
8309
+ "M100.99 28.93L91.76 27.8C88.5 27.41 87.19 26.58 87.19 25.1C87.19 23.45 89.98 22.49 94.29 22.49C99.38 22.49 102.43 23.36 104.17 26.58L111.61 24.27C108.65 17.87 102.08 16 94.98 16C85.1 16 78.7 19.31 78.7 25.97C78.7 31.02 82.49 33.81 90.28 34.76L98.68 35.8C102.29 36.24 103.3 37.19 103.3 38.5C103.3 40.24 100.64 41.46 95.59 41.46C89.41 41.46 87.01 40.2 85.31 37.02L77.52 39.33C80.31 45.77 86.92 47.95 95.07 47.95C105.17 47.95 111.88 44.55 111.88 37.72C111.88 32.84 108.53 29.84 101 28.93H100.99Z",
8310
+ "M132.73 22.66C137 22.66 140.87 24.01 142.7 28.54L150.49 26.67C148.79 20.66 142.3 16.05 132.6 16.05C121.41 16.05 113.44 22.23 113.44 31.98C113.44 41.73 121.41 48 132.6 48C142.31 48 148.8 43.3 150.49 37.29L142.7 35.42C140.87 39.95 137 41.3 132.73 41.3C126.94 41.3 122.24 38.38 122.24 32.42V31.55C122.24 25.59 126.94 22.67 132.73 22.67V22.66Z",
8311
+ "M161.51 34.89H179.58V28.71H161.51V22.97H181.54V16.52H152.8V47.43H181.84V40.99H161.51V34.89Z",
8312
+ "M208.71 33.85H208.67L193.86 16.52H184.94V47.43H193.04V27.49H193.08L210.15 47.43H216.81V16.52H208.71V33.85Z",
8313
+ "M252.25 26.8C252.25 20.14 248.07 16.52 239.84 16.52H220.77V47.43H229.48V37.5H236.62L242.63 47.43H252.56L245.55 36.37C249.9 34.8 252.25 31.62 252.25 26.79V26.8ZM243.54 27.32C243.54 29.98 242.06 31.28 239.06 31.28H229.48V22.96H239.06C242.06 22.96 243.54 24.27 243.54 26.88V27.32Z",
8314
+ "M263.79 16.52H255.08V47.43H263.79V16.52Z"
8315
+ ];
8316
+ var CODE_PAGE_COPY = {
8317
+ title: "Enter your code",
8318
+ where: "Find it in Scenri on your computer, under Settings > Local access.",
8319
+ label: "6-digit code",
8320
+ open: "Open Scenri",
8321
+ opening: "Opening\u2026",
8322
+ foot: "Your code keeps Scenri private.",
8323
+ wrong: "That code didn't work. Try again.",
8324
+ locked: "Too many tries. Try again in a few minutes.",
8325
+ short: "Enter all 6 digits.",
8326
+ offline: "Can't reach Scenri. Check your Wi-Fi."
8327
+ };
8328
+ var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
8329
+ var symbolIcon = () => {
8330
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>path{fill:#0a0a0a}@media(prefers-color-scheme:dark){path{fill:#f5f5f5}}</style>${LOCKUP_PATHS.slice(
8331
+ 0,
8332
+ 4
8333
+ ).map((d) => `<path d="${d}"/>`).join("")}</svg>`;
8334
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
8335
+ };
8336
+ var STYLE = `
8337
+ :root {
8338
+ color-scheme: light dark;
8339
+ --page: #f6f6f6; --card: #ffffff; --ink: #0a0a0a; --on-ink: #ffffff; --soft: #666666; --faint: #767676;
8340
+ --line: #e8e8e8; --field: #f4f4f4; --field-line: #e4e4e4; --ring: rgba(10, 10, 10, 0.08);
8341
+ --off: #e6e6e6; --off-ink: #9a9a9a; --red: #d93025; --green: #188038;
8342
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
8343
+ }
8344
+ @media (prefers-color-scheme: dark) {
8345
+ :root {
8346
+ --page: #0d0d0d; --card: #141414; --ink: #f5f5f5; --on-ink: #0a0a0a; --soft: #9a9a9a; --faint: #858585;
8347
+ --line: #262626; --field: #1a1a1a; --field-line: #2e2e2e; --ring: rgba(245, 245, 245, 0.1);
8348
+ --off: #262626; --off-ink: #6e6e6e; --red: #ff6b62; --green: #4ade80;
8349
+ }
8350
+ }
8351
+ * { box-sizing: border-box; }
8352
+ html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
8353
+ body {
8354
+ margin: 0; background: var(--card); color: var(--ink);
8355
+ font: 16px/1.45 var(--font); -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent;
8356
+ }
8357
+ .shell {
8358
+ min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; align-items: center;
8359
+ padding: max(44px, calc(env(safe-area-inset-top) + 28px)) max(24px, env(safe-area-inset-right))
8360
+ max(28px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
8361
+ }
8362
+ .lockup { display: block; width: auto; height: 22px; margin: 0 0 40px; color: var(--ink); }
8363
+ .card { width: 100%; max-width: 380px; text-align: center; }
8364
+ h1 { margin: 0; font-size: 26px; line-height: 1.15; font-weight: 700; letter-spacing: -0.025em; }
8365
+ .where { margin: 10px auto 30px; max-width: 29ch; color: var(--soft); font-size: 15px; text-wrap: balance; }
8366
+ .vh {
8367
+ position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
8368
+ clip: rect(0 0 0 0); white-space: nowrap; border: 0;
8369
+ }
8370
+ .otp { position: relative; }
8371
+ .slots {
8372
+ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 12px repeat(3, minmax(0, 1fr));
8373
+ gap: 8px; max-width: 344px; margin: 0 auto;
8374
+ }
8375
+ .slot {
8376
+ position: relative; display: flex; align-items: center; justify-content: center; aspect-ratio: 4 / 5;
8377
+ border: 1px solid var(--field-line); border-radius: 12px; background: var(--field);
8378
+ font-size: 28px; font-weight: 600; line-height: 1; font-variant-numeric: tabular-nums; color: var(--ink);
8379
+ transition: border-color 0.14s ease, box-shadow 0.14s ease, background-color 0.14s ease;
8380
+ }
8381
+ .slot[data-on] { border-color: var(--ink); box-shadow: 0 0 0 3px var(--ring); background: var(--card); }
8382
+ .slot[data-on]:empty::after {
8383
+ content: ""; width: 2px; height: 28px; border-radius: 1px; background: var(--ink);
8384
+ animation: blink 1.06s steps(1, end) infinite;
8385
+ }
8386
+ .sep { align-self: center; justify-self: center; width: 10px; height: 2px; border-radius: 1px; background: var(--field-line); }
8387
+ .otp[data-state="error"] .slot { border-color: var(--red); box-shadow: none; }
8388
+ .otp[data-state="error"] .slots { animation: shake 0.36s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
8389
+ .otp[data-state="ok"] .slot { border-color: var(--green); }
8390
+ .otp[data-state="busy"] .slot { opacity: 0.55; }
8391
+ /* the real field, drawn by the boxes: transparent over them, so any box can be tapped */
8392
+ .js .otp input {
8393
+ position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; margin: 0; padding: 0;
8394
+ border: 0; outline: 0; background: transparent; color: transparent; -webkit-text-fill-color: transparent;
8395
+ caret-color: transparent; font-size: 16px; text-indent: -9999px; cursor: text;
8396
+ -webkit-appearance: none; appearance: none;
8397
+ }
8398
+ .js .otp input::selection { background: transparent; }
8399
+ .js .otp input:-webkit-autofill { transition: background-color 600000s 0s, color 600000s 0s; }
8400
+ /* without script: a plain field that still takes the code and submits */
8401
+ .nojs .slots { display: none; }
8402
+ .nojs .otp input {
8403
+ display: block; width: 100%; max-width: 344px; height: 60px; margin: 0 auto; padding: 0 16px;
8404
+ border: 1px solid var(--field-line); border-radius: 12px; background: var(--field); color: var(--ink);
8405
+ font: 600 28px/1 var(--font); letter-spacing: 0.3em; text-align: center; font-variant-numeric: tabular-nums;
8406
+ }
8407
+ .msg { min-height: 20px; margin: 12px 0 16px; color: var(--red); font-size: 14px; line-height: 20px; }
8408
+ .go {
8409
+ display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%; max-width: 344px;
8410
+ height: 52px; margin: 0 auto; border: 0; border-radius: 999px; background: var(--ink); color: var(--on-ink);
8411
+ font: 600 16px/1 var(--font); letter-spacing: -0.01em; cursor: pointer; touch-action: manipulation;
8412
+ transition: opacity 0.15s ease, transform 0.1s ease;
8413
+ }
8414
+ /* its own colours when it cannot be pressed yet, never a faded ink */
8415
+ .go:disabled { background: var(--off); color: var(--off-ink); cursor: default; }
8416
+ .otp[data-state="busy"] ~ .go:disabled, .otp[data-state="ok"] ~ .go:disabled { background: var(--ink); color: var(--on-ink); }
8417
+ .go:active:not(:disabled) { transform: scale(0.985); }
8418
+ .go:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
8419
+ .spin {
8420
+ width: 16px; height: 16px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%;
8421
+ animation: spin 0.7s linear infinite;
8422
+ }
8423
+ /* one short line, centred with the page, the lock beside it; if large text
8424
+ wraps it anyway, it wraps balanced and still centred */
8425
+ .foot {
8426
+ max-width: 34ch; margin: 36px auto 0; color: var(--faint); font-size: 13px; line-height: 18px;
8427
+ text-align: center; text-wrap: balance;
8428
+ }
8429
+ .foot svg { display: inline-block; width: 13px; height: 13px; margin-right: 6px; vertical-align: -2px; }
8430
+ @keyframes blink { 50% { opacity: 0; } }
8431
+ @keyframes spin { to { transform: rotate(360deg); } }
8432
+ @keyframes shake {
8433
+ 10%, 90% { transform: translateX(-1px); } 20%, 80% { transform: translateX(3px); }
8434
+ 30%, 50%, 70% { transform: translateX(-6px); } 40%, 60% { transform: translateX(6px); }
8435
+ }
8436
+ /* a phone with its keyboard up (Android resizes the page for it): everything moves up */
8437
+ @media (max-height: 560px) {
8438
+ .shell { padding-top: max(20px, calc(env(safe-area-inset-top) + 12px)); }
8439
+ .lockup { height: 18px; margin-bottom: 18px; }
8440
+ h1 { font-size: 22px; }
8441
+ .where { margin: 6px auto 18px; font-size: 14px; }
8442
+ .foot { display: none; }
8443
+ }
8444
+ /* a tablet or a computer: the same content, on a card in the middle of the page */
8445
+ @media (min-width: 560px) and (min-height: 600px) {
8446
+ body { background: var(--page); }
8447
+ .shell { justify-content: center; padding-bottom: max(10vh, 28px); }
8448
+ .lockup { margin-bottom: 32px; }
8449
+ .card {
8450
+ max-width: 420px; padding: 40px 36px 32px; border: 1px solid var(--line); border-radius: 20px;
8451
+ background: var(--card); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 16px 40px -16px rgba(0, 0, 0, 0.14);
8452
+ }
8453
+ .foot { margin-top: 24px; }
8454
+ }
8455
+ @media (prefers-reduced-motion: reduce) {
8456
+ .slot, .go { transition: none; }
8457
+ .slot[data-on]:empty::after, .otp[data-state="error"] .slots { animation: none; }
8458
+ .spin { animation-duration: 1.6s; }
8459
+ }
8460
+ @media (forced-colors: active) {
8461
+ .slot { border-color: CanvasText; }
8462
+ .slot[data-on] { outline: 2px solid Highlight; }
8463
+ }
8464
+ `;
8465
+ var SCRIPT = (copy) => `
8466
+ (function () {
8467
+ var LEN = 6, COPY = ${JSON.stringify({ open: copy.open, opening: copy.opening, wrong: copy.wrong, locked: copy.locked, short: copy.short, offline: copy.offline })};
8468
+ var form = document.getElementById('form'), input = document.getElementById('code');
8469
+ var otp = document.getElementById('otp'), msg = document.getElementById('msg'), go = document.getElementById('go');
8470
+ var slots = Array.prototype.slice.call(document.querySelectorAll('.slot'));
8471
+ var busy = false;
8472
+ input.maxLength = LEN;
8473
+ function digits(s) { return String(s || '').replace(/\\D/g, '').slice(0, LEN); }
8474
+ function fromPaste(s) { var m = /[?&]t=([^&#\\s]+)/.exec(s || ''); return digits(m ? m[1] : s); }
8475
+ function toEnd() { var n = input.value.length; try { input.setSelectionRange(n, n); } catch (e) {} }
8476
+ function paint() {
8477
+ var v = input.value, on = document.activeElement === input && !busy;
8478
+ for (var i = 0; i < LEN; i++) {
8479
+ slots[i].textContent = v.charAt(i);
8480
+ if (on && i === Math.min(v.length, LEN - 1)) slots[i].setAttribute('data-on', '');
8481
+ else slots[i].removeAttribute('data-on');
8482
+ }
8483
+ go.disabled = busy || v.length !== LEN;
8484
+ }
8485
+ function state(s, text) {
8486
+ otp.removeAttribute('data-state');
8487
+ void otp.offsetWidth;
8488
+ if (s) otp.setAttribute('data-state', s);
8489
+ msg.textContent = text || '';
8490
+ input.setAttribute('aria-invalid', s === 'error' ? 'true' : 'false');
8491
+ }
8492
+ function label(busyNow) {
8493
+ go.textContent = busyNow ? COPY.opening : COPY.open;
8494
+ if (!busyNow) return;
8495
+ var s = document.createElement('span');
8496
+ s.className = 'spin'; s.setAttribute('aria-hidden', 'true');
8497
+ go.insertBefore(s, go.firstChild);
8498
+ }
8499
+ function fail(kind, clear) {
8500
+ busy = false; label(false); state('error', COPY[kind]);
8501
+ if (navigator.vibrate) navigator.vibrate(40);
8502
+ if (clear) setTimeout(function () { input.value = ''; paint(); input.focus(); }, 420);
8503
+ paint();
8504
+ }
8505
+ function submit() {
8506
+ var code = digits(input.value);
8507
+ if (busy) return;
8508
+ if (code.length !== LEN) { state('error', COPY.short); input.focus(); return; }
8509
+ busy = true; state('busy', ''); label(true); paint();
8510
+ fetch('/api/phone?t=' + code, { headers: { accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' })
8511
+ .then(function (res) {
8512
+ if (res.ok) {
8513
+ state('ok', '');
8514
+ var u = new URL(location.href); u.searchParams.delete('t');
8515
+ location.replace(u.pathname + u.search + u.hash);
8516
+ return;
8517
+ }
8518
+ return res.json().catch(function () { return {}; }).then(function (b) {
8519
+ fail(b && b.error === 'too many tries' ? 'locked' : 'wrong', true);
8520
+ });
8521
+ })
8522
+ .catch(function () { fail('offline', false); });
8523
+ }
8524
+ input.addEventListener('input', function () {
8525
+ var clean = digits(input.value);
8526
+ if (clean !== input.value) input.value = clean;
8527
+ if (otp.getAttribute('data-state') === 'error') state('', '');
8528
+ toEnd(); paint();
8529
+ if (clean.length === LEN) submit();
8530
+ });
8531
+ input.addEventListener('paste', function (e) {
8532
+ var text = e.clipboardData ? e.clipboardData.getData('text') : '';
8533
+ e.preventDefault();
8534
+ input.value = fromPaste(text);
8535
+ input.dispatchEvent(new Event('input', { bubbles: true }));
8536
+ });
8537
+ ['focus', 'blur', 'click', 'select', 'keyup'].forEach(function (t) {
8538
+ input.addEventListener(t, function () { if (t !== 'blur') toEnd(); paint(); });
8539
+ });
8540
+ // a form will not submit itself while its button is disabled, so Enter is heard here
8541
+ input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); submit(); } });
8542
+ form.addEventListener('submit', function (e) { e.preventDefault(); submit(); });
8543
+ window.addEventListener('pageshow', function (e) {
8544
+ if (!e.persisted) return;
8545
+ busy = false; input.value = ''; label(false); state('', ''); paint();
8546
+ });
8547
+ if (msg.textContent) state('error', msg.textContent);
8548
+ paint();
8549
+ input.focus();
8550
+ })();
8551
+ `;
8552
+ var LOCK = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="7" width="10" height="7" rx="1.75"/><path d="M5.5 7V5.25a2.5 2.5 0 0 1 5 0V7"/></svg>`;
8553
+ function codePage(problem) {
8554
+ const c = CODE_PAGE_COPY;
8555
+ const note = problem ? c[problem] : "";
8556
+ const slot = '<span class="slot"></span>';
8557
+ return `<!doctype html>
8558
+ <html lang="en" class="nojs">
8559
+ <head>
8560
+ <meta charset="utf-8">
8561
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
8562
+ <meta name="robots" content="noindex">
8563
+ <meta name="color-scheme" content="light dark">
8564
+ <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
8565
+ <meta name="theme-color" content="#0d0d0d" media="(prefers-color-scheme: dark)">
8566
+ <meta name="format-detection" content="telephone=no">
8567
+ <title>Scenri</title>
8568
+ <link rel="icon" href="${symbolIcon()}">
8569
+ <script>document.documentElement.className = 'js';</script>
8570
+ <style>${STYLE}</style>
8571
+ </head>
8572
+ <body>
8573
+ <main class="shell">
8574
+ <svg class="lockup" viewBox="0 0 263.79 64" fill="currentColor" role="img" aria-label="Scenri">${LOCKUP_PATHS.map((d) => `<path d="${d}"/>`).join("")}</svg>
8575
+ <section class="card" aria-labelledby="title">
8576
+ <h1 id="title">${esc(c.title)}</h1>
8577
+ <p class="where" id="where">${esc(c.where)}</p>
8578
+ <form id="form" method="get" action="" novalidate>
8579
+ <div class="otp" id="otp">
8580
+ <label class="vh" for="code">${esc(c.label)}</label>
8581
+ <input id="code" name="t" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="7" autocomplete="one-time-code" enterkeyhint="go" autocapitalize="off" autocorrect="off" spellcheck="false" autofocus required aria-describedby="where msg" data-1p-ignore data-lpignore="true" data-bwignore data-form-type="other">
8582
+ <div class="slots" aria-hidden="true">${slot.repeat(3)}<span class="sep"></span>${slot.repeat(3)}</div>
8583
+ </div>
8584
+ <p class="msg" id="msg" role="alert">${esc(note)}</p>
8585
+ <button type="submit" class="go" id="go">${esc(c.open)}</button>
8586
+ </form>
8587
+ </section>
8588
+ <p class="foot">${LOCK}<span>${esc(c.foot)}</span></p>
8589
+ </main>
8590
+ <script>${SCRIPT(c)}</script>
8591
+ </body>
8592
+ </html>
8593
+ `;
8594
+ }
8595
+
8596
+ // src/network/hosts.ts
8597
+ var LOOPBACK_NAMES = ["localhost", "127.0.0.1", "::1"];
8598
+ var isLoopbackName = (host) => LOOPBACK_NAMES.includes(host);
8599
+ var isWildcardHost = (host) => host === "0.0.0.0" || host === "::";
8600
+ var isLoopbackAddress = (addr) => !!addr && (addr === "::1" || addr.startsWith("127.") || addr.startsWith("::ffff:127."));
8601
+ function isIPv4Literal(host) {
8602
+ const parts = host.split(".");
8603
+ return !isWildcardHost(host) && parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
8604
+ }
8605
+ var VIRTUAL = /^(utun|tun|tap|ppp|ipsec|wg|zt|gif|stf|awdl|llw|anpi|bridge|docker|br-|veth|virbr|vmnet|vboxnet|lxc|lxd|cni|flannel|cali|tailscale)|virtual|vmware|hyper-v|vethernet|wsl|tailscale|zerotier|wireguard|openvpn|nordlynx|tap-windows|bluetooth|loopback/i;
8606
+ var PHYSICAL = /^(en\d|eth\d|wl|wlan|enp|eno|ens|wi-?fi|wlan|ethernet|wireless)/i;
8607
+ function octets(address) {
8608
+ const parts = address.split(".");
8609
+ if (parts.length !== 4) return null;
8610
+ const nums = parts.map((p) => Number(p));
8611
+ return nums.every((n, i) => /^\d{1,3}$/.test(parts[i]) && n >= 0 && n <= 255) ? nums : null;
8612
+ }
8613
+ function rangeRank(address) {
8614
+ const o = octets(address);
8615
+ if (!o) return null;
8616
+ if (o[0] === 192 && o[1] === 168) return 0;
8617
+ if (o[0] === 10) return 1;
8618
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return 2;
8619
+ if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return 3;
8620
+ return null;
8621
+ }
8622
+ var isIPv4 = (a) => a.family === "IPv4" || a.family === 4;
8623
+ function lanCandidates(ifaces, routeAddress = null) {
8624
+ const found = [];
8625
+ for (const [name, addrs] of Object.entries(ifaces)) {
8626
+ if (VIRTUAL.test(name)) continue;
8627
+ for (const a of addrs ?? []) {
8628
+ if (!isIPv4(a) || a.internal) continue;
8629
+ if (a.mac === "00:00:00:00:00:00" && !PHYSICAL.test(name)) continue;
8630
+ const range = rangeRank(a.address);
8631
+ if (range === null) continue;
8632
+ found.push({ address: a.address, name, physical: PHYSICAL.test(name), range });
8633
+ }
8634
+ }
8635
+ found.sort(
8636
+ (a, b) => Number(b.address === routeAddress) - Number(a.address === routeAddress) || Number(b.physical) - Number(a.physical) || a.range - b.range || a.name.localeCompare(b.name) || a.address.localeCompare(b.address, void 0, { numeric: true })
8637
+ );
8638
+ return [...new Set(found.map((f) => f.address))];
8639
+ }
8640
+ function defaultRouteAddress() {
8641
+ return new Promise((resolve) => {
8642
+ let socket = null;
8643
+ const done = (value) => {
8644
+ try {
8645
+ socket?.close();
8646
+ } catch {
8647
+ }
8648
+ socket = null;
8649
+ resolve(value);
8650
+ };
8651
+ try {
8652
+ socket = createSocket("udp4");
8653
+ socket.once("error", () => done(null));
8654
+ socket.connect(9, "192.0.2.1", () => {
8655
+ try {
8656
+ done(socket?.address().address ?? null);
8657
+ } catch {
8658
+ done(null);
8659
+ }
8660
+ });
8661
+ setTimeout(() => done(null), 500).unref();
8662
+ } catch {
8663
+ done(null);
8664
+ }
8665
+ });
8666
+ }
8667
+ async function phoneAddresses() {
8668
+ let ifaces = {};
8669
+ try {
8670
+ ifaces = networkInterfaces();
8671
+ } catch {
8672
+ return [];
8673
+ }
8674
+ return lanCandidates(ifaces, await defaultRouteAddress());
8675
+ }
8676
+ function ownAddresses() {
8677
+ const out = /* @__PURE__ */ new Set();
8678
+ try {
8679
+ for (const addrs of Object.values(networkInterfaces())) for (const a of addrs ?? []) out.add(a.address);
8680
+ } catch {
8681
+ }
8682
+ return out;
8683
+ }
8684
+ var run = (cmd, args, opts) => new Promise((resolve, reject) => {
8685
+ execFile(
8686
+ cmd,
8687
+ args,
8688
+ { windowsHide: true, encoding: "utf8", timeout: opts?.timeout ?? 8e3 },
8689
+ (err, stdout, stderr) => err ? reject(Object.assign(err, { stderr })) : resolve(stdout)
8690
+ );
8691
+ });
8692
+ var SOCKETFILTERFW = "/usr/libexec/ApplicationFirewall/socketfilterfw";
8693
+ var PROMPT_MS = 18e4;
8694
+ async function macVerdict(nodePath, exec = run) {
8695
+ const state = await exec(SOCKETFILTERFW, ["--getglobalstate"]);
8696
+ if (/disabled|State = 0/i.test(state)) return "ok";
8697
+ if (!/enabled|State = [12]/i.test(state)) return "unknown";
8698
+ const all = await exec(SOCKETFILTERFW, ["--getblockall"]);
8699
+ if (/block all state set to enabled|State = 2/i.test(all)) return "blocks-all";
8700
+ const app = await exec(SOCKETFILTERFW, ["--getappblocked", nodePath]);
8701
+ if (/is blocked/i.test(app)) return "blocked";
8702
+ if (/is permitted/i.test(app)) return "ok";
8703
+ return "unknown";
8704
+ }
8705
+ var ruleName = (port) => `Scenri-${port}`;
8706
+ var psQuote = (s) => `'${s.replace(/'/g, "''")}'`;
8707
+ function windowsScript(nodePath, port) {
8708
+ return [
8709
+ PS_UTF8,
8710
+ "$ErrorActionPreference='SilentlyContinue';",
8711
+ "$c=(Get-NetConnectionProfile | Where-Object { $_.IPv4Connectivity -ne 'Disconnected' } | Select-Object -First 1).NetworkCategory;",
8712
+ `$n=if ($c -eq 'DomainAuthenticated') { 'Domain' } else { "$c" };`,
8713
+ "$on=(Get-NetFirewallProfile -Name $n).Enabled;",
8714
+ `$r=Get-NetFirewallApplicationFilter -Program ${psQuote(nodePath)} | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' };`,
8715
+ "$b=@($r | Where-Object { $_.Action -eq 'Block' } | ForEach-Object { $_.Profile.ToString() }) -join ';';",
8716
+ "$a=@($r | Where-Object { $_.Action -eq 'Allow' } | ForEach-Object { $_.Profile.ToString() }) -join ';';",
8717
+ `$s=[bool](Get-NetFirewallRule -Name ${psQuote(ruleName(port))} | Where-Object { $_.Enabled -eq 'True' });`,
8718
+ 'Write-Output "$n|$on|$b|$a|$s"'
8719
+ ].join(" ");
8720
+ }
8721
+ var covers = (profiles, profile) => profiles.split(";").filter(Boolean).some((p) => /\bAny\b/i.test(p) || p.split(",").some((x) => x.trim().toLowerCase() === profile.toLowerCase()));
8722
+ function parseWindows(out) {
8723
+ const line = out.trim().split(/\r?\n/).pop() ?? "";
8724
+ const [profile, on, block = "", allow = "", ours = ""] = line.split("|");
8725
+ if (!profile || !["Public", "Private", "Domain"].includes(profile)) return "unknown";
8726
+ if (/^false$/i.test(on ?? "")) return "ok";
8727
+ if (covers(block, profile)) return "blocked";
8728
+ if (/^true$/i.test(ours) || covers(allow, profile)) return "ok";
8729
+ return "blocked";
8730
+ }
8731
+ function windowsAllowScript(nodePath, port) {
8732
+ return [
8733
+ "$ErrorActionPreference='Stop';",
8734
+ "try {",
8735
+ `Get-NetFirewallApplicationFilter -Program ${psQuote(nodePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Direction -eq 'Inbound' -and $_.Action -eq 'Block' } | Remove-NetFirewallRule;`,
8736
+ `Remove-NetFirewallRule -Name ${psQuote(ruleName(port))} -ErrorAction SilentlyContinue;`,
8737
+ `New-NetFirewallRule -Name ${psQuote(ruleName(port))} -DisplayName 'Scenri (phones on this network)' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${port} -RemoteAddress LocalSubnet -Profile Any | Out-Null;`,
8738
+ "exit 0",
8739
+ "} catch { exit 1 }"
8740
+ ].join(" ");
8741
+ }
8742
+ var encoded = (script) => Buffer.from(script, "utf16le").toString("base64");
8743
+ function windowsElevate(nodePath, port, env) {
8744
+ const ps = powershellPath(env);
8745
+ const inner = `'-NoProfile','-NonInteractive','-WindowStyle','Hidden','-EncodedCommand','${encoded(windowsAllowScript(nodePath, port))}'`;
8746
+ return [
8747
+ "-NoProfile",
8748
+ "-NonInteractive",
8749
+ "-Command",
8750
+ `try { $p = Start-Process -FilePath ${psQuote(ps)} -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList ${inner}; exit $p.ExitCode } catch { exit 1223 }`
8751
+ ];
8752
+ }
8753
+ var macAllowArgs = (nodePath) => [
8754
+ "-e",
8755
+ "on run argv",
8756
+ "-e",
8757
+ "set p to quoted form of item 1 of argv",
8758
+ "-e",
8759
+ `do shell script "${SOCKETFILTERFW} --add " & p & " && ${SOCKETFILTERFW} --unblockapp " & p with administrator privileges`,
8760
+ "-e",
8761
+ "end run",
8762
+ nodePath
8763
+ ];
8764
+ async function allowScenri(opts) {
8765
+ const platform = opts.platform ?? process.platform;
8766
+ const nodePath = opts.nodePath ?? process.execPath;
8767
+ const exec = opts.exec ?? run;
8768
+ const env = opts.env ?? process.env;
8769
+ try {
8770
+ if (platform === "darwin") {
8771
+ await exec("/usr/bin/osascript", macAllowArgs(nodePath), { timeout: PROMPT_MS });
8772
+ return "done";
8773
+ }
8774
+ if (platform === "win32") {
8775
+ await exec(powershellPath(env), windowsElevate(nodePath, opts.port, env), { timeout: PROMPT_MS });
8776
+ return "done";
8777
+ }
8778
+ return "unsupported";
8779
+ } catch (err) {
8780
+ const e = err;
8781
+ const said = `${e.message ?? ""} ${e.stderr ?? ""}`;
8782
+ if (e.code === 1223 || /\(-128\)|User cancel/i.test(said)) return "cancelled";
8783
+ return "failed";
8784
+ }
8785
+ }
8786
+ async function firewallVerdict(opts) {
8787
+ const platform = opts.platform ?? process.platform;
8788
+ const nodePath = opts.nodePath ?? process.execPath;
8789
+ const exec = opts.exec ?? run;
8790
+ try {
8791
+ if (platform === "darwin") return await macVerdict(nodePath, exec);
8792
+ if (platform === "win32") {
8793
+ const out = await exec(powershellPath(opts.env ?? process.env), [
8794
+ "-NoProfile",
8795
+ "-NonInteractive",
8796
+ "-Command",
8797
+ windowsScript(nodePath, opts.port)
8798
+ ]);
8799
+ return parseWindows(out);
8800
+ }
8801
+ } catch {
8802
+ }
8803
+ return "unknown";
8804
+ }
8805
+
8806
+ // src/network/phoneAccess.ts
8807
+ var CODE_ALPHABET = "0123456789";
8808
+ var CODE_LENGTH = 6;
8809
+ var CODE_SETTING = "access.code";
8810
+ var SYNC_MS = 3e4;
8811
+ function newCode() {
8812
+ let out = "";
8813
+ for (let i = 0; i < CODE_LENGTH; i++) out += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
8814
+ return out;
8815
+ }
8816
+ var normalizeCode = (s) => s.replace(/[\s-]/g, "").toUpperCase();
8817
+ var groupCode = (code) => `${code.slice(0, 3)} ${code.slice(3)}`;
8818
+ function deviceOf(ua) {
8819
+ const s = ua ?? "";
8820
+ if (/iPhone/i.test(s)) return "iPhone";
8821
+ if (/iPad/i.test(s)) return "iPad";
8822
+ if (/Android/i.test(s)) return /Mobile/i.test(s) ? "Android phone" : "Android tablet";
8823
+ if (/Macintosh|Mac OS X/i.test(s)) return "Mac or iPad";
8824
+ if (/Windows/i.test(s)) return "Windows computer";
8825
+ if (/CrOS/i.test(s)) return "Chromebook";
8826
+ if (/Linux/i.test(s)) return "computer";
8827
+ return "device";
8828
+ }
8829
+ var FRESH_MS = 5e3;
8830
+ function listenWith(handler) {
8831
+ return (address, port) => new Promise((resolve, reject) => {
8832
+ const server = createServer(handler());
8833
+ server.keepAliveTimeout = 72e3;
8834
+ server.once("error", reject);
8835
+ server.listen({ port, host: address, exclusive: true }, () => {
8836
+ server.off("error", reject);
8837
+ server.on("error", () => void 0);
8838
+ resolve({
8839
+ close: () => new Promise((done) => {
8840
+ server.close(() => done());
8841
+ server.closeAllConnections();
8842
+ })
8843
+ });
8844
+ });
8845
+ });
8846
+ }
8847
+ function createPhoneAccess(deps) {
8848
+ const { store } = deps;
8849
+ const bind = (deps.bind ?? "").trim();
8850
+ const mode = !bind ? "listeners" : isLoopbackName(bind) ? "none" : isWildcardHost(bind) ? "wildcard" : "fixed";
8851
+ const addresses = deps.addresses ?? phoneAddresses;
8852
+ const own = deps.own ?? ownAddresses;
8853
+ const now2 = deps.now ?? Date.now;
8854
+ const listen = deps.listen ?? listenWith(deps.handler ?? (() => () => void 0));
8855
+ const firewall = deps.firewall ?? ((p) => firewallVerdict({ port: p }));
8856
+ const allowThrough = deps.allow ?? ((p) => allowScenri({ port: p }));
8857
+ let code = store.getSetting(CODE_SETTING);
8858
+ if (!code || !new RegExp(`^\\d{${CODE_LENGTH}}$`).test(normalizeCode(code))) {
8859
+ code = newCode();
8860
+ store.setSetting(CODE_SETTING, code);
8861
+ }
8862
+ let theCode = normalizeCode(code);
8863
+ let port = null;
8864
+ const open = /* @__PURE__ */ new Map();
8865
+ let candidates = [];
8866
+ let syncedAt = -Infinity;
8867
+ let chain = Promise.resolve();
8868
+ let timer = null;
8869
+ let closed = false;
8870
+ let lastVisit = null;
8871
+ let verdict = null;
8872
+ const syncOnce = async () => {
8873
+ if (closed) return;
8874
+ candidates = mode === "none" ? [] : mode === "fixed" ? [bind] : await addresses().catch(() => []);
8875
+ syncedAt = now2();
8876
+ if (mode !== "listeners" || port === null) return;
8877
+ const want = new Set(candidates);
8878
+ for (const [address, closer] of open) {
8879
+ if (want.has(address)) continue;
8880
+ open.delete(address);
8881
+ await closer.close().catch(() => void 0);
8882
+ }
8883
+ for (const address of candidates) {
8884
+ if (open.has(address) || closed) continue;
8885
+ try {
8886
+ open.set(address, await listen(address, port));
8887
+ } catch {
8888
+ }
8889
+ }
8890
+ };
8891
+ const sync = () => {
8892
+ chain = chain.then(syncOnce, syncOnce);
8893
+ return chain;
8894
+ };
8895
+ const readFirewall = async () => {
8896
+ if (port === null) return "unknown";
8897
+ if (verdict && now2() - verdict.at < 2e4) return verdict.value;
8898
+ const value = await firewall(port).catch(() => "unknown");
8899
+ verdict = { at: now2(), value };
8900
+ return value;
8901
+ };
8902
+ const reachable = () => mode === "listeners" ? candidates.filter((a) => open.has(a)) : candidates;
8903
+ return {
8904
+ get code() {
8905
+ return theCode;
8906
+ },
8907
+ renew() {
8908
+ theCode = newCode();
8909
+ store.setSetting(CODE_SETTING, theCode);
8910
+ return theCode;
8911
+ },
8912
+ async start(p) {
8913
+ port = p;
8914
+ await sync();
8915
+ if (mode === "listeners" && !closed) {
8916
+ timer = setInterval(() => void sync(), deps.syncMs ?? SYNC_MS);
8917
+ timer.unref();
8918
+ }
8919
+ },
8920
+ sync,
8921
+ async close() {
8922
+ closed = true;
8923
+ if (timer) clearInterval(timer);
8924
+ await chain.catch(() => void 0);
8925
+ const all = [...open.values()];
8926
+ open.clear();
8927
+ await Promise.all(all.map((c) => c.close().catch(() => void 0)));
8928
+ },
8929
+ async status(thisComputer, fresh = false) {
8930
+ if (fresh || now2() - syncedAt >= FRESH_MS) await sync();
8931
+ const origins = port === null ? [] : reachable().map((a) => `http://${a.includes(":") ? `[${a}]` : a}:${port}`);
8932
+ const address = origins[0] ?? null;
8933
+ return {
8934
+ reach: mode === "none" ? "this-computer" : "network",
8935
+ thisComputer,
8936
+ platform: deps.platform ?? process.platform,
8937
+ address,
8938
+ url: address ? `${address}/?t=${theCode}` : null,
8939
+ code: theCode,
8940
+ others: origins.slice(1),
8941
+ problem: mode === "none" || port === null ? null : candidates.length === 0 ? "no-network" : origins.length === 0 ? "blocked" : null,
8942
+ lastVisit
8943
+ };
8944
+ },
8945
+ visit(remote, ua) {
8946
+ const at = now2();
8947
+ if (lastVisit && at - lastVisit.at < 1e3) return;
8948
+ if (remote && own().has(remote.replace(/^::ffff:/, ""))) return;
8949
+ lastVisit = { at, device: deviceOf(ua) };
8950
+ },
8951
+ firewall: readFirewall,
8952
+ async allow() {
8953
+ if (port === null) return { result: "unsupported", firewall: "unknown" };
8954
+ const result = await allowThrough(port).catch(() => "failed");
8955
+ verdict = null;
8956
+ if (result === "done" && mode === "listeners") {
8957
+ chain = chain.then(async () => {
8958
+ const all = [...open.values()];
8959
+ open.clear();
8960
+ await Promise.all(all.map((c) => c.close().catch(() => void 0)));
8961
+ });
8962
+ await sync();
8963
+ }
8964
+ return { result, firewall: await readFirewall() };
8965
+ }
8966
+ };
8967
+ }
8968
+
8969
+ // src/access.ts
8300
8970
  var ACCESS_COOKIE = "sc_access";
8971
+ var cookieName = (port) => port ? `${ACCESS_COOKIE}_${port}` : ACCESS_COOKIE;
8301
8972
  var LEGACY_ACCESS_COOKIE = "bt_access";
8973
+ var COOKIE_MAX_AGE_S = 400 * 24 * 60 * 60;
8974
+ var WRONG_LIMIT = 10;
8975
+ var WRONG_LIMIT_ALL = 100;
8976
+ var WRONG_WINDOW_MS = 10 * 6e4;
8977
+ var ADDRESSES_KEPT = 5e3;
8302
8978
  function hostnameOf(hostHeader) {
8303
8979
  if (!hostHeader) return null;
8304
8980
  const h = hostHeader.trim().toLowerCase();
@@ -8311,44 +8987,105 @@ function hostnameOf(hostHeader) {
8311
8987
  const colon = h.lastIndexOf(":");
8312
8988
  return colon === -1 ? h : h.slice(0, colon);
8313
8989
  }
8314
- function tokenMatches(expected, supplied) {
8315
- if (!supplied) return false;
8990
+ function fromThisComputer(req) {
8991
+ const host = hostnameOf(req.headers.host);
8992
+ return !!host && isLoopbackName(host) && isLoopbackAddress(req.socket?.remoteAddress);
8993
+ }
8994
+ function codeMatches(expected, supplied) {
8316
8995
  const a = Buffer.from(expected);
8317
- const b = Buffer.from(supplied);
8318
- if (a.length !== b.length) return false;
8319
- return timingSafeEqual(a, b);
8996
+ const b = Buffer.from(normalizeCode(supplied));
8997
+ return a.length === b.length && timingSafeEqual(a, b);
8320
8998
  }
8321
8999
  function cookieValue(header, name) {
8322
9000
  for (const part of (header ?? "").split(";")) {
8323
9001
  const eq = part.indexOf("=");
8324
9002
  if (eq === -1) continue;
8325
- if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim());
9003
+ if (part.slice(0, eq).trim() !== name) continue;
9004
+ try {
9005
+ return decodeURIComponent(part.slice(eq + 1).trim());
9006
+ } catch {
9007
+ return void 0;
9008
+ }
8326
9009
  }
8327
9010
  return void 0;
8328
9011
  }
9012
+ function createLimiter(now2) {
9013
+ const seen = /* @__PURE__ */ new Map();
9014
+ let everyone = { since: now2(), wrong: /* @__PURE__ */ new Set() };
9015
+ const live = (t) => now2() - t.since <= WRONG_WINDOW_MS;
9016
+ const all = () => {
9017
+ if (!live(everyone)) everyone = { since: now2(), wrong: /* @__PURE__ */ new Set() };
9018
+ return everyone;
9019
+ };
9020
+ const entry = (ip) => {
9021
+ const e = seen.get(ip);
9022
+ if (e && live(e)) return e;
9023
+ seen.delete(ip);
9024
+ return null;
9025
+ };
9026
+ return {
9027
+ locked: (ip) => all().wrong.size >= WRONG_LIMIT_ALL || (entry(ip)?.wrong.size ?? 0) >= WRONG_LIMIT,
9028
+ wrong(ip, value) {
9029
+ const v = normalizeCode(value);
9030
+ all().wrong.add(v);
9031
+ let e = entry(ip);
9032
+ if (!e) {
9033
+ e = { since: now2(), wrong: /* @__PURE__ */ new Set() };
9034
+ seen.set(ip, e);
9035
+ }
9036
+ e.wrong.add(v);
9037
+ if (seen.size > ADDRESSES_KEPT) {
9038
+ for (const [k, t] of seen) if (!live(t)) seen.delete(k);
9039
+ while (seen.size > ADDRESSES_KEPT) seen.delete(seen.keys().next().value);
9040
+ }
9041
+ }
9042
+ };
9043
+ }
9044
+ function refuse(req, reply, problem) {
9045
+ const accept = String(req.headers.accept ?? "");
9046
+ const isPage = (req.method === "GET" || req.method === "HEAD") && (req.headers["sec-fetch-mode"] === "navigate" || accept.includes("text/html"));
9047
+ if (isPage) {
9048
+ return reply.status(403).header("content-type", "text/html; charset=utf-8").header("cache-control", "no-store").send(codePage(problem));
9049
+ }
9050
+ const error = problem === "locked" ? "too many tries" : problem === "wrong" ? "wrong code" : "access code required";
9051
+ return reply.status(403).send({ error });
9052
+ }
8329
9053
  function registerAccessGuard(app, opts = {}) {
8330
- const allowed = /* @__PURE__ */ new Set([...LOOPBACK, ...(opts.allowedHosts ?? []).map((h) => h.trim().toLowerCase())]);
8331
- const { token } = opts;
9054
+ const named = /* @__PURE__ */ new Set([...LOOPBACK_NAMES, ...(opts.allowedHosts ?? []).map((h) => h.trim().toLowerCase())]);
9055
+ const limiter = createLimiter(opts.now ?? Date.now);
8332
9056
  app.addHook("onRequest", async (req, reply) => {
8333
9057
  const host = hostnameOf(req.headers.host);
8334
- if (!host || !allowed.has(host)) {
9058
+ if (!host || !(named.has(host) || isIPv4Literal(host))) {
8335
9059
  return reply.status(403).send({ error: "forbidden host" });
8336
9060
  }
8337
9061
  if (req.headers["sec-fetch-site"] === "cross-site") {
8338
9062
  const isNavigation = req.headers["sec-fetch-mode"] === "navigate" && (req.method === "GET" || req.method === "HEAD");
8339
9063
  if (!isNavigation) return reply.status(403).send({ error: "cross-site request blocked" });
8340
9064
  }
8341
- if (!token) return;
9065
+ const code = opts.code?.();
9066
+ if (!code || fromThisComputer(req)) return;
9067
+ const ip = req.socket?.remoteAddress ?? "";
8342
9068
  const q = req.query?.t;
8343
- const fromQuery = typeof q === "string" ? q : void 0;
9069
+ const fromQuery = typeof q === "string" && q ? q : void 0;
8344
9070
  const header = req.headers["x-access-token"];
8345
- const supplied = fromQuery ?? (typeof header === "string" ? header : void 0) ?? cookieValue(req.headers.cookie, ACCESS_COOKIE) ?? cookieValue(req.headers.cookie, LEGACY_ACCESS_COOKIE);
8346
- if (!tokenMatches(token, supplied)) {
8347
- return reply.status(403).send({ error: "access token required" });
8348
- }
8349
- if (fromQuery) {
8350
- reply.header("set-cookie", `${ACCESS_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict`);
9071
+ const fromHeader = typeof header === "string" && header ? header : void 0;
9072
+ const port = opts.port?.() ?? null;
9073
+ const fromCookie = cookieValue(req.headers.cookie, cookieName(port)) ?? cookieValue(req.headers.cookie, ACCESS_COOKIE) ?? cookieValue(req.headers.cookie, LEGACY_ACCESS_COOKIE);
9074
+ if (limiter.locked(ip)) return refuse(req, reply, "locked");
9075
+ const supplied = [fromCookie, fromQuery, fromHeader].filter((v) => !!v);
9076
+ if (supplied.some((v) => codeMatches(code, v))) {
9077
+ if (fromQuery && codeMatches(code, fromQuery) && !(fromCookie && codeMatches(code, fromCookie))) {
9078
+ reply.header(
9079
+ "set-cookie",
9080
+ `${cookieName(port)}=${encodeURIComponent(code)}; Path=/; Max-Age=${COOKIE_MAX_AGE_S}; HttpOnly; SameSite=Strict`
9081
+ );
9082
+ }
9083
+ opts.onVisit?.(ip, req.headers["user-agent"]);
9084
+ return;
8351
9085
  }
9086
+ for (const v of supplied) limiter.wrong(ip, v);
9087
+ if (supplied.length && limiter.locked(ip)) return refuse(req, reply, "locked");
9088
+ return refuse(req, reply, fromQuery ? "wrong" : null);
8352
9089
  });
8353
9090
  }
8354
9091
 
@@ -9335,14 +10072,14 @@ async function seamResidual(image, plan, source) {
9335
10072
  const between = (a, b) => {
9336
10073
  const limit = horizontal ? W : H;
9337
10074
  if (a < 0 || b < 0 || a >= limit || b >= limit) return null;
9338
- const run3 = horizontal ? H : W;
10075
+ const run4 = horizontal ? H : W;
9339
10076
  let sum = 0;
9340
- for (let i = 0; i < run3; i++) {
10077
+ for (let i = 0; i < run4; i++) {
9341
10078
  const ia = (horizontal ? i * W + a : a * W + i) * ch;
9342
10079
  const ib = (horizontal ? i * W + b : b * W + i) * ch;
9343
10080
  sum += Math.abs(data[ia] - data[ib]) + Math.abs(data[ia + 1] - data[ib + 1]) + Math.abs(data[ia + 2] - data[ib + 2]);
9344
10081
  }
9345
- return sum / (run3 * 3);
10082
+ return sum / (run4 * 3);
9346
10083
  };
9347
10084
  const near = horizontal ? plan.left : plan.top;
9348
10085
  const far = near + (horizontal ? source.width : source.height);
@@ -10344,13 +11081,13 @@ function beginRun(deps, id, view, chained) {
10344
11081
  });
10345
11082
  }
10346
11083
  function endRun(deps, id, view, aborted) {
10347
- const run3 = runs.get(id);
10348
- if (!run3 || run3.status !== "running") return;
11084
+ const run4 = runs.get(id);
11085
+ if (!run4 || run4.status !== "running") return;
10349
11086
  const rec = getPresenterDraft(deps.core, id);
10350
11087
  const error = view ? rec?.views[view].error : rec?.readError;
10351
11088
  const failed = !aborted && !!error && error !== "cancelled";
10352
11089
  runs.set(id, {
10353
- ...run3,
11090
+ ...run4,
10354
11091
  view,
10355
11092
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
10356
11093
  status: aborted ? "cancelled" : failed ? "failed" : "done",
@@ -11976,7 +12713,7 @@ function startSceneStudioJob(deps, input) {
11976
12713
  prune2(input.brandId);
11977
12714
  const ctrl = new AbortController();
11978
12715
  running4.set(job.id, ctrl);
11979
- const work = run(deps, job, { ...input, instruction, imageHashes: hashes, reading, ask }, ctrl.signal).finally(() => {
12716
+ const work = run2(deps, job, { ...input, instruction, imageHashes: hashes, reading, ask }, ctrl.signal).finally(() => {
11980
12717
  running4.delete(job.id);
11981
12718
  tasks.delete(job.id);
11982
12719
  });
@@ -12017,7 +12754,7 @@ async function read(deps, job, input, signal) {
12017
12754
  if (!checked.ok) throw fail2(checked.error);
12018
12755
  return checked.reading;
12019
12756
  }
12020
- async function run(deps, job, input, signal) {
12757
+ async function run2(deps, job, input, signal) {
12021
12758
  try {
12022
12759
  let reading = input.reading ?? null;
12023
12760
  if (input.kind !== "again") {
@@ -12280,7 +13017,7 @@ function createSceneExamples(deps) {
12280
13017
  if (gone && gone !== hashOf2(example.file)) deps.release([gone]);
12281
13018
  return wrote;
12282
13019
  };
12283
- async function run3(job, signal) {
13020
+ async function run4(job, signal) {
12284
13021
  const engine = await deps.engine();
12285
13022
  if (!engine) throw new Error("Nothing can draw these right now. Connect Codex and try again.");
12286
13023
  const engineId = engine.capabilities().id;
@@ -12478,7 +13215,7 @@ function createSceneExamples(deps) {
12478
13215
  jobs2.set(k, job);
12479
13216
  const ctrl = new AbortController();
12480
13217
  controllers2.set(k, ctrl);
12481
- const task = run3(job, ctrl.signal).then(() => {
13218
+ const task = run4(job, ctrl.signal).then(() => {
12482
13219
  job.status = ctrl.signal.aborted ? "cancelled" : job.failed.length && !job.done.length ? "failed" : "done";
12483
13220
  if (job.status === "failed") job.error = job.failed[0].error;
12484
13221
  }).catch((err) => {
@@ -12745,21 +13482,21 @@ function sceneWork(j) {
12745
13482
  }
12746
13483
  function presenterWork(core, brandId) {
12747
13484
  const out = [];
12748
- for (const run3 of presenterDraftRuns(brandId)) {
12749
- const rec = getPresenterDraft(core, run3.draftId);
13485
+ for (const run4 of presenterDraftRuns(brandId)) {
13486
+ const rec = getPresenterDraft(core, run4.draftId);
12750
13487
  if (!rec) continue;
12751
13488
  const wanted = rec.extras ? PRESENTER_VIEWS : CORE_VIEWS;
12752
- const view = run3.view;
13489
+ const view = run4.view;
12753
13490
  out.push({
12754
- id: `presenter:${run3.draftId}:${run3.id}`,
13491
+ id: `presenter:${run4.draftId}:${run4.id}`,
12755
13492
  kind: "presenter",
12756
- status: run3.status,
12757
- step: run3.status === "running" ? view ?? "reading" : view,
13493
+ status: run4.status,
13494
+ step: run4.status === "running" ? view ?? "reading" : view,
12758
13495
  name: rec.name,
12759
13496
  thumb: rec.views.portrait.hash ?? null,
12760
- startedAt: run3.startedAt,
12761
- finishedAt: run3.finishedAt,
12762
- error: run3.error,
13497
+ startedAt: run4.startedAt,
13498
+ finishedAt: run4.finishedAt,
13499
+ error: run4.error,
12763
13500
  draftId: rec.id,
12764
13501
  presenterId: rec.presenterId ?? null,
12765
13502
  done: wanted.filter((v) => rec.views[v].status === "approved").length,
@@ -13196,6 +13933,25 @@ function registerImageRoutes(app, deps) {
13196
13933
 
13197
13934
  // src/release/notes.data.ts
13198
13935
  var RELEASES = [
13936
+ {
13937
+ version: "0.16.0",
13938
+ date: "2026-09-24",
13939
+ title: "Open Scenri on your phone or tablet, and Settings with one page for each thing.",
13940
+ sections: [
13941
+ {
13942
+ heading: "Local access",
13943
+ body: "Scenri now opens on a phone, tablet or another computer on the same Wi-Fi. Scan the QR code in Settings > Local access, or type the address and enter the six-digit code. The first start may ask whether Node can accept incoming connections; allow it, and if the firewall still stands in the way, Local access says so and, where it can, Allow Scenri fixes it through your computer's own prompt."
13944
+ },
13945
+ {
13946
+ heading: "Security",
13947
+ body: "Only devices with the code get in, and wrong guesses are slowed to a crawl. New code, on the computer running Scenri, signs every device out."
13948
+ },
13949
+ {
13950
+ heading: "Settings",
13951
+ body: "Providers, Appearance, Library, Local access, Updates and About each have their own page, and every delete sits under Danger zone. On a phone, a link to a Settings page opens that page."
13952
+ }
13953
+ ]
13954
+ },
13199
13955
  {
13200
13956
  version: "0.15.1",
13201
13957
  date: "2026-09-23",
@@ -14158,13 +14914,16 @@ function isNewsworthy(entry) {
14158
14914
  // src/routes/updates.ts
14159
14915
  function registerUpdateRoutes(app, deps) {
14160
14916
  const { core, meta, updates, runtime } = deps;
14161
- app.get("/api/version", async () => ({
14917
+ app.get("/api/version", async (req) => ({
14162
14918
  name: meta.name,
14163
14919
  version: meta.version,
14164
14920
  schema: SCHEMA_VERSION,
14165
14921
  installKind: runtime.installKind,
14166
14922
  supervised: runtime.supervised,
14167
- home: core.home
14923
+ home: core.home,
14924
+ // Settings hides what acts on this computer (its file manager, its
14925
+ // desktop) from a phone that opened Scenri over the Wi-Fi
14926
+ thisComputer: fromThisComputer(req)
14168
14927
  }));
14169
14928
  const ghSlug = repoSlug(meta.repository);
14170
14929
  const UNRELEASED = "0.0.0";
@@ -14356,7 +15115,8 @@ function registerSystemRoutes(app, deps) {
14356
15115
  const dbBytes = (await stat(dbPath).catch(() => null))?.size ?? 0;
14357
15116
  return { dir: core.home, dbPath, images: files, bytes: bytes + dbBytes };
14358
15117
  });
14359
- app.post("/api/system/reveal", async (_req, reply) => {
15118
+ app.post("/api/system/reveal", async (req, reply) => {
15119
+ if (!fromThisComputer(req)) return reply.status(403).send({ error: "Only on the computer running Scenri." });
14360
15120
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
14361
15121
  try {
14362
15122
  spawn(cmd, [core.home], { detached: true, stdio: "ignore" }).unref();
@@ -14682,7 +15442,8 @@ function registerDesktopRoutes(app, deps) {
14682
15442
  installKind: runtime.installKind
14683
15443
  };
14684
15444
  });
14685
- app.post("/api/desktop/install", async (_req, reply) => {
15445
+ app.post("/api/desktop/install", async (req, reply) => {
15446
+ if (!fromThisComputer(req)) return reply.status(403).send({ error: "Only on the computer running Scenri." });
14686
15447
  if (runtime.installKind === "dev") {
14687
15448
  return reply.status(409).send({
14688
15449
  error: "Running from a source checkout; there is no installed build to put on a desktop.",
@@ -14716,6 +15477,32 @@ function registerDesktopRoutes(app, deps) {
14716
15477
  });
14717
15478
  }
14718
15479
 
15480
+ // src/routes/phone.ts
15481
+ function registerPhoneRoutes(app, deps) {
15482
+ const { phone } = deps;
15483
+ app.get(
15484
+ "/api/phone",
15485
+ async (req) => phone.status(fromThisComputer(req), req.query?.fresh === "1")
15486
+ );
15487
+ app.post("/api/phone/code", async (req, reply) => {
15488
+ if (!fromThisComputer(req)) {
15489
+ return reply.status(403).send({ error: "Change this on the computer running Scenri." });
15490
+ }
15491
+ phone.renew();
15492
+ return reply.send(await phone.status(true));
15493
+ });
15494
+ app.get("/api/phone/help", async (req, reply) => {
15495
+ if (!fromThisComputer(req)) return reply.send({ firewall: "unknown" });
15496
+ return reply.send({ firewall: await phone.firewall() });
15497
+ });
15498
+ app.post("/api/phone/allow", async (req, reply) => {
15499
+ if (!fromThisComputer(req)) {
15500
+ return reply.status(403).send({ error: "Change this on the computer running Scenri." });
15501
+ }
15502
+ return reply.send(await phone.allow());
15503
+ });
15504
+ }
15505
+
14719
15506
  // src/server.ts
14720
15507
  function seedFor(sourceHash, width, height) {
14721
15508
  let h = 2166136261;
@@ -14730,7 +15517,22 @@ function buildServer(opts) {
14730
15517
  const { core, engines } = opts;
14731
15518
  const meta = readMeta();
14732
15519
  const app = Fastify({ logger: false });
14733
- registerAccessGuard(app, opts.access);
15520
+ const phone = createPhoneAccess({ store: core.store, bind: opts.bind, handler: () => app.routing, ...opts.phone });
15521
+ app.decorate("phone", phone);
15522
+ app.addHook("onClose", async () => phone.close());
15523
+ let port = null;
15524
+ registerAccessGuard(app, {
15525
+ ...opts.access,
15526
+ code: () => phone.code,
15527
+ port: () => {
15528
+ if (port === null) {
15529
+ const addr = app.server.address();
15530
+ port = addr && typeof addr === "object" ? addr.port : null;
15531
+ }
15532
+ return port;
15533
+ },
15534
+ onVisit: (remote, ua) => phone.visit(remote, ua)
15535
+ });
14734
15536
  const reserved = /* @__PURE__ */ new Map();
14735
15537
  const runningGenerations = /* @__PURE__ */ new Map();
14736
15538
  core.catalog.reconcileInterruptedJobs();
@@ -16302,6 +17104,7 @@ function buildServer(opts) {
16302
17104
  return drained;
16303
17105
  });
16304
17106
  registerSystemRoutes(app, { core, thumbs });
17107
+ registerPhoneRoutes(app, { phone });
16305
17108
  registerGuideRoutes(app, { core, version: meta.version });
16306
17109
  registerDesktopRoutes(app, {
16307
17110
  core,
@@ -16330,24 +17133,26 @@ function withStoredAssets(sent, stored) {
16330
17133
  return out;
16331
17134
  }
16332
17135
 
17136
+ // src/banner.ts
17137
+ function localUrl(host, port, code) {
17138
+ if (host === "::1") return `http://[::1]:${port}`;
17139
+ if (isLoopbackName(host) || isWildcardHost(host)) return `http://127.0.0.1:${port}`;
17140
+ return `http://${host.includes(":") ? `[${host}]` : host}:${port}/?t=${code}`;
17141
+ }
17142
+ function startLines(info) {
17143
+ const lines = [`Scenri Studio \u2192 ${localUrl(info.host, info.port, info.phone?.code ?? "")}`];
17144
+ if (info.phone?.address) lines.push(`on your phone \u2192 ${info.phone.address} code ${groupCode(info.phone.code)}`);
17145
+ lines.push(`data dir \u2192 ${info.home}`, "Keep this window open while Scenri is running.");
17146
+ if (!info.studioBuilt) lines.push("", "(studio UI not built, API only. Run: pnpm build)");
17147
+ return lines;
17148
+ }
17149
+
16333
17150
  // src/serve.ts
16334
17151
  var PORT = Number(process.env.SCENRI_PORT || 4747);
16335
17152
  var HOST = process.env.SCENRI_HOST || "127.0.0.1";
16336
- var LOOPBACK2 = ["127.0.0.1", "localhost", "::1"];
16337
- function lanAddresses() {
16338
- const out = [];
16339
- for (const addrs of Object.values(networkInterfaces())) {
16340
- for (const a of addrs ?? []) {
16341
- if (a.family === "IPv4" || a.family === 4) {
16342
- if (!a.internal) out.push(a.address);
16343
- }
16344
- }
16345
- }
16346
- return out;
16347
- }
16348
17153
  async function serve() {
16349
17154
  try {
16350
- await run2();
17155
+ await run3();
16351
17156
  } catch (err) {
16352
17157
  if (err instanceof SchemaTooNewError) {
16353
17158
  console.error(`
@@ -16358,7 +17163,7 @@ async function serve() {
16358
17163
  throw err;
16359
17164
  }
16360
17165
  }
16361
- async function run2() {
17166
+ async function run3() {
16362
17167
  const core = createCore();
16363
17168
  const stubs = process.env.SCENRI_DEMO_ENGINE === "1" ? [createDemoEngine((b) => core.images.save(b), demoOptionsFromEnv(process.env))] : [];
16364
17169
  const engines = createEngineRegistry(core, stubs);
@@ -16373,16 +17178,15 @@ async function run2() {
16373
17178
  const studioDist = candidates.find((p) => existsSync(p));
16374
17179
  const supervised = process.env.SCENRI_SUPERVISED === "1";
16375
17180
  const installKind = detectInstallKind(fileURLToPath(import.meta.url), core.home);
16376
- const onlyThisMachine = LOOPBACK2.includes(HOST);
16377
- const token = onlyThisMachine ? void 0 : randomBytes(24).toString("base64url");
16378
- const reachableAt = onlyThisMachine ? [] : HOST === "0.0.0.0" || HOST === "::" ? lanAddresses() : [HOST];
17181
+ const named = isLoopbackName(HOST) || isWildcardHost(HOST) || isIPv4Literal(HOST) ? [] : [HOST];
16379
17182
  const app = buildServer({
16380
17183
  core,
16381
17184
  engines,
16382
17185
  analyzer,
16383
17186
  sizeReader,
16384
17187
  studioDist,
16385
- access: { allowedHosts: reachableAt, token },
17188
+ access: { allowedHosts: named },
17189
+ bind: process.env.SCENRI_HOST,
16386
17190
  runtime: {
16387
17191
  installKind,
16388
17192
  supervised,
@@ -16451,32 +17255,26 @@ async function run2() {
16451
17255
  void repairPresenterCrops(core, (line) => console.log(line)).catch(() => void 0);
16452
17256
  app.updates.schedule();
16453
17257
  app.content.schedule();
16454
- const query = token ? `/?t=${token}` : "";
16455
- const localUrl = `http://127.0.0.1:${PORT}${query}`;
16456
- console.log(`
16457
- Scenri Studio \u2192 ${localUrl}`);
16458
- for (const ip of reachableAt) console.log(` on your network \u2192 http://${ip}:${PORT}${query}`);
16459
- console.log(` data dir \u2192 ${core.home}`);
16460
- console.log(" Keep this window open while Scenri is running.\n");
16461
- if (!studioDist) console.log(" (studio UI not built, API only. Run: pnpm build)\n");
16462
- if (token) {
16463
- console.log(" Warning: the studio is reachable from your network.");
16464
- console.log(" Anyone who opens a link above can generate on your API keys and");
16465
- console.log(" delete your library. The token in the URL is the only thing gating");
16466
- console.log(" that, and it is new on every run.");
16467
- console.log(" For this machine only, unset SCENRI_HOST.\n");
16468
- }
17258
+ const port = app.server.address()?.port ?? PORT;
17259
+ await app.phone.start(port).catch(() => void 0);
17260
+ const phone = await app.phone.status(true).catch(() => null);
17261
+ const localUrl2 = localUrl(HOST, port, app.phone.code);
17262
+ console.log("");
17263
+ for (const line of startLines({ host: HOST, port, home: core.home, studioBuilt: !!studioDist, phone })) {
17264
+ console.log(line ? ` ${line}` : "");
17265
+ }
17266
+ console.log("");
16469
17267
  if (process.env.SCENRI_NO_OPEN !== "1") {
16470
17268
  let told = false;
16471
17269
  const tellUrl = () => {
16472
17270
  if (told) return;
16473
17271
  told = true;
16474
- console.log(` Open ${localUrl} in your browser.
17272
+ console.log(` Open ${localUrl2} in your browser.
16475
17273
  `);
16476
17274
  };
16477
17275
  try {
16478
17276
  const { default: open } = await import('open');
16479
- const child = await open(localUrl);
17277
+ const child = await open(localUrl2);
16480
17278
  child.once("error", tellUrl);
16481
17279
  child.once("exit", (openExit) => {
16482
17280
  if (openExit !== null && openExit !== 0) tellUrl();