pinokiod 8.2.29 → 8.2.31

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.
@@ -40,8 +40,29 @@ body.dark .anywhere-card { border-color: rgba(255,255,255,0.1); background: rgba
40
40
  .anywhere-muted { opacity: 0.7; font-size: 13px; }
41
41
  .anywhere-grid { display: grid; grid-template-columns: minmax(280px, 380px) 1fr; gap: 16px; align-items: start; }
42
42
  @media (max-width: 900px) { .anywhere-grid { grid-template-columns: 1fr; } }
43
- .anywhere-qr { display: flex; flex-direction: column; align-items: center; gap: 10px; text-align: center; }
44
- .anywhere-qr img { width: 220px; height: 220px; border-radius: 8px; background: white; padding: 6px; }
43
+ .anywhere-signout, .anywhere-linkbtn { background: none; border: none; padding: 0; font: inherit; color: inherit; text-decoration: underline; cursor: pointer; }
44
+ /* which device is connecting: a segmented control, the phone panel by default */
45
+ .anywhere-segments { display: grid; grid-template-columns: 1fr 1fr; width: 100%; padding: 4px; border-radius: 12px; background: rgba(0,0,0,0.05); }
46
+ body.dark .anywhere-segments { background: rgba(255,255,255,0.08); }
47
+ .anywhere-segments label { position: relative; display: block; }
48
+ .anywhere-segments input { position: absolute; opacity: 0; width: 0; height: 0; }
49
+ .anywhere-segments span { display: block; text-align: center; padding: 9px 12px; border-radius: 9px; font-size: 14px; cursor: pointer; opacity: 0.7; transition: opacity 0.15s; }
50
+ .anywhere-segments span:hover { opacity: 1; }
51
+ .anywhere-segments input:checked + span { background: white; font-weight: bold; opacity: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.12); }
52
+ body.dark .anywhere-segments input:checked + span { background: rgba(255,255,255,0.16); box-shadow: none; }
53
+ .anywhere-segments input:focus-visible + span { outline: 2px solid currentColor; outline-offset: 1px; }
54
+ #anywhere-connect[data-device=phone] [data-panel=computer], #anywhere-connect[data-device=computer] [data-panel=phone] { display: none; }
55
+ #anywhere-connect [data-panel] { display: flex; flex-direction: column; align-items: center; gap: 14px; width: 100%; }
56
+ #anywhere-connect [data-panel] p { margin: 0; max-width: 360px; font-size: 15px; line-height: 1.5; }
57
+ #anywhere-connect [data-panel=computer] { padding-top: 10px; }
58
+ .anywhere-warning { padding: 10px 14px; border-radius: 8px; background: rgba(255,140,0,0.14); font-size: 13px; line-height: 1.5; margin: 16px 0 0; }
59
+ .anywhere-warning a { color: inherit; }
60
+ .anywhere-connect-card { max-width: 440px; padding: 22px 24px 24px; }
61
+ .anywhere-connect-title { text-align: center; font-size: 17px; margin: 0 0 14px; }
62
+ .anywhere-qr { display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; }
63
+ .anywhere-qr img { width: 212px; height: 212px; border-radius: 14px; background: white; padding: 12px; border: 1px solid rgba(0,0,0,0.08); margin-top: 4px; }
64
+ body.dark .anywhere-qr img { border-color: transparent; }
65
+ .anywhere-qr > .anywhere-muted { font-size: 12px; margin-top: 2px; }
45
66
  .anywhere-address {
46
67
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
47
68
  font-size: 15px; word-break: break-all;
@@ -76,9 +97,7 @@ body.dark .anywhere-row { border-top-color: rgba(255,255,255,0.06); }
76
97
  .anywhere-row a.remote { font-family: ui-monospace, Menlo, monospace; font-size: 13px; color: royalblue; text-decoration: none; word-break: break-all; }
77
98
  .anywhere-dot { width: 9px; height: 9px; border-radius: 50%; background: silver; flex-shrink: 0; }
78
99
  .anywhere-dot.online { background: yellowgreen; }
79
- .anywhere-steps { padding-left: 20px; margin: 8px 0; }
80
- .anywhere-steps li { margin: 6px 0; line-height: 1.5; }
81
- .anywhere-waiting { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 8px; background: rgba(255,200,0,0.15); font-size: 13px; }
100
+ .anywhere-waiting { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; background: rgba(255,200,0,0.15); font-size: 12px; }
82
101
  .anywhere-waiting.joined { background: rgba(154,205,50,0.2); }
83
102
  .anywhere-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px 12px; border-radius: 8px; background: rgba(0,0,0,0.05); }
84
103
  body.dark .anywhere-switch { background: rgba(255,255,255,0.08); }
@@ -170,7 +189,8 @@ document.addEventListener('DOMContentLoaded', function() {
170
189
  <div class='anywhere-state' data-state='login'>
171
190
  <div class='anywhere-card'>
172
191
  <h2>Sign in in your browser</h2>
173
- <p>Remote access uses <b>Tailscale</b>, a free private network. Sign in with a <b>personal</b> Google, Apple, GitHub or Microsoft account. Use the same account on every device you want to connect.</p>
192
+ <p>Remote uses <b>Tailscale</b>, a free private network. Sign in with a <b>personal</b> Google, Apple, GitHub or Microsoft account. Use the same account on every device you want to connect.</p>
193
+ <p>After signing in you may be asked to approve one thing. Both happen in your browser.</p>
174
194
  <p><a class='anywhere-btn' id='anywhere-login-link' href='#' target='_blank' rel='noopener'><i class="fa-solid fa-arrow-up-right-from-square"></i> Sign in with Tailscale</a></p>
175
195
  <p class='anywhere-muted'>Waiting for you to finish signing in… <span id='anywhere-login-url' class='anywhere-muted'></span></p>
176
196
  </div>
@@ -186,11 +206,12 @@ document.addEventListener('DOMContentLoaded', function() {
186
206
 
187
207
  <div class='anywhere-state' data-state='https'>
188
208
  <div class='anywhere-card'>
189
- <h2>One more click: enable HTTPS</h2>
190
- <p id='anywhere-approval-text'>Tailscale needs your permission once to issue HTTPS certificates for your network. Apps need HTTPS for things like camera and microphone access in your browser.</p>
191
- <p><a class='anywhere-btn' id='anywhere-approval-link' href='#' target='_blank' rel='noopener'><i class="fa-solid fa-lock"></i> Enable HTTPS</a>
209
+ <h2>One more approval</h2>
210
+ <p>A one-time approval so your devices can connect securely. It opens in your browser; if it didn't, use the button.</p>
211
+ <p id='anywhere-approval-text' class='anywhere-muted'></p>
212
+ <p><a class='anywhere-btn' id='anywhere-approval-link' href='#' target='_blank' rel='noopener'><i class="fa-solid fa-lock"></i> Approve in Tailscale</a>
192
213
  <a class='anywhere-btn secondary' href='https://login.tailscale.com/admin/dns' target='_blank' rel='noopener'>Open DNS settings</a></p>
193
- <p class='anywhere-muted'>Waiting for HTTPS to be enabled… (in the admin console: DNS → HTTPS Certificates → Enable)</p>
214
+ <p class='anywhere-muted'>Waiting for the approval… (in the admin console: DNS → HTTPS Certificates → Enable)</p>
194
215
  </div>
195
216
  </div>
196
217
 
@@ -203,29 +224,33 @@ document.addEventListener('DOMContentLoaded', function() {
203
224
  </div>
204
225
 
205
226
  <div class='anywhere-state' data-state='ready'>
227
+ <div class='anywhere-warning' id='anywhere-expiry-warning' hidden></div>
206
228
  <div class='anywhere-grid'>
207
- <div class='anywhere-card'>
208
- <h2>Connect another device</h2>
209
- <div class='anywhere-qr'>
210
- <img id='anywhere-qr' alt='QR code to connect' src=''>
211
- <div class='anywhere-address'><a id='anywhere-address' href='#' target='_blank' rel='noopener'></a></div>
212
- <button type='button' class='anywhere-btn secondary anywhere-copy' id='anywhere-copy' disabled><i class='fa-regular fa-copy' aria-hidden='true'></i> Copy URL</button>
213
- <div id='anywhere-copy-status' class='anywhere-muted' role='status' aria-live='polite'></div>
214
- <div id='anywhere-copy-manual' class='anywhere-copy-manual' hidden>
215
- <input id='anywhere-copy-value' type='text' readonly aria-label='Remote URL' aria-describedby='anywhere-copy-status'>
229
+ <div class='anywhere-card anywhere-connect-card'>
230
+ <h2 class='anywhere-connect-title'>Connect a device</h2>
231
+ <div class='anywhere-qr' id='anywhere-connect' data-device='phone'>
232
+ <div class='anywhere-segments'>
233
+ <label><input type='radio' name='anywhere-device' value='phone' checked><span>Phone or tablet</span></label>
234
+ <label><input type='radio' name='anywhere-device' value='computer'><span>Another computer</span></label>
216
235
  </div>
217
- <div class='anywhere-muted'>signed in as <b id='anywhere-account'></b></div>
236
+ <div data-panel='phone'>
237
+ <img id='anywhere-qr' alt='QR code to connect' src=''>
238
+ <p>Scan with your phone. It walks you through the rest.</p>
239
+ </div>
240
+ <div data-panel='computer'>
241
+ <p>Open this link on the other computer. It walks you through installing Tailscale and signing in as <b class='anywhere-account-inline'></b>, then opens this Pinokio.</p>
242
+ <div class='anywhere-address'><a id='anywhere-address' href='#' target='_blank' rel='noopener'></a></div>
243
+ <p><button type='button' class='anywhere-btn secondary anywhere-copy' id='anywhere-copy' disabled><i class='fa-regular fa-copy' aria-hidden='true'></i> Copy link</button></p>
244
+ <div id='anywhere-copy-status' class='anywhere-muted' role='status' aria-live='polite'></div>
245
+ <div id='anywhere-copy-manual' class='anywhere-copy-manual' hidden>
246
+ <input id='anywhere-copy-value' type='text' readonly aria-label='Remote link' aria-describedby='anywhere-copy-status'>
247
+ </div>
248
+ </div>
249
+ <div class='anywhere-muted'>signed in as <b id='anywhere-account'></b> · <button type='button' class='anywhere-signout' data-action='logout'>Sign out</button></div>
218
250
  <div class='anywhere-waiting' id='anywhere-waiting'><i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for another device to join…</div>
219
251
  </div>
220
- <h3>Steps</h3>
221
- <ol class='anywhere-steps'>
222
- <li>Install <b>Tailscale</b> on the computer, phone, or tablet you want to connect from.</li>
223
- <li>Sign in with <b class='anywhere-account-inline'></b> and turn the connection on.</li>
224
- <li>On that device, scan the QR code or open the address above in a browser.</li>
225
- </ol>
226
- <p class='anywhere-muted'>Bookmark the address for quick access. On a phone or tablet, you can also use <i>Add to Home Screen</i>.</p>
227
252
  </div>
228
- <div>
253
+ <div id='anywhere-details' hidden>
229
254
  <div class='anywhere-card'>
230
255
  <h2>Running apps</h2>
231
256
  <p class='anywhere-muted'>Every app Pinokio launches gets its own address automatically. Entries disappear when an app stops.</p>
@@ -233,6 +258,13 @@ document.addEventListener('DOMContentLoaded', function() {
233
258
  <h3>Published addresses</h3>
234
259
  <div id='anywhere-ports' class='anywhere-muted'></div>
235
260
  </div>
261
+ <% if (locals.loopback) { %>
262
+ <div class='anywhere-card'>
263
+ <h2>Your other Pinokios</h2>
264
+ <p class='anywhere-muted'>Computers on your network with Remote on. They open through this Pinokio; nothing else to install.</p>
265
+ <div id='anywhere-others'><p class='anywhere-muted'>Looking…</p></div>
266
+ </div>
267
+ <% } %>
236
268
  <div class='anywhere-card'>
237
269
  <h2>Devices on your network</h2>
238
270
  <div id='anywhere-peers'></div>
@@ -246,7 +278,6 @@ document.addEventListener('DOMContentLoaded', function() {
246
278
  <dt>Daemon</dt><dd id='anywhere-version'></dd>
247
279
  </dl>
248
280
  <p style='margin-top:14px'>
249
- <button class='anywhere-btn secondary' data-action='logout'><i class="fa-solid fa-right-from-bracket"></i> Sign out / switch account</button>
250
281
  <button class='anywhere-btn secondary' data-action='stop'><i class="fa-solid fa-power-off"></i> Turn off</button>
251
282
  </p>
252
283
  </div>
@@ -274,15 +305,18 @@ document.addEventListener('DOMContentLoaded', function() {
274
305
  let current = <%- JSON.stringify(status || {}).replace(/</g, '\\u003c') %>
275
306
  let busy = false
276
307
  let copying = false
308
+ // the approval URL opened without a click, kept for the window so a reload does not open it again
309
+ let approvalOpened = null
310
+ try { approvalOpened = sessionStorage.getItem('anywhere-approval-opened') } catch (_) { /* storage unavailable: once per page load */ }
277
311
 
278
312
  copyButton.addEventListener('click', async () => {
279
- const url = current.address
313
+ const url = current.connectUrl
280
314
  if (copying || current.phase !== 'ready' || !url) return
281
315
  const previousFocus = document.activeElement
282
316
  copying = true
283
317
  copyButton.disabled = true
284
318
  copyStatus.textContent = 'Copying…'
285
- const stillCurrent = () => current.phase === 'ready' && current.address === url
319
+ const stillCurrent = () => current.phase === 'ready' && current.connectUrl === url
286
320
  let copied = false
287
321
  try {
288
322
  // Call directly from the click: Safari requires a user gesture.
@@ -295,7 +329,7 @@ document.addEventListener('DOMContentLoaded', function() {
295
329
  if (!copied) {
296
330
  copyValue.value = url
297
331
  copyManual.hidden = false
298
- copyStatus.textContent = 'Automatic copy is unavailable. Touch and hold the URL to copy it, or press Ctrl+C / ⌘C.'
332
+ copyStatus.textContent = 'Automatic copy is unavailable. Touch and hold the link to copy it, or press Ctrl+C / ⌘C.'
299
333
  try {
300
334
  copyValue.focus({ preventScroll: true })
301
335
  copyValue.select()
@@ -305,11 +339,11 @@ document.addEventListener('DOMContentLoaded', function() {
305
339
  }
306
340
  if (copied) {
307
341
  copyManual.hidden = true
308
- copyStatus.textContent = 'URL copied.'
342
+ copyStatus.textContent = 'Link copied.'
309
343
  }
310
344
  }
311
345
  copying = false
312
- copyButton.disabled = current.phase !== 'ready' || !current.address
346
+ copyButton.disabled = current.phase !== 'ready' || !current.connectUrl
313
347
  if (copied && stillCurrent() && previousFocus &&
314
348
  (document.activeElement === copyValue || document.activeElement === document.body)) {
315
349
  previousFocus.focus({ preventScroll: true })
@@ -325,12 +359,12 @@ document.addEventListener('DOMContentLoaded', function() {
325
359
  window.open(href, '_blank')
326
360
  }
327
361
  }
362
+ const daysUntil = (value) => value ? Math.round((new Date(value).getTime() - Date.now()) / 86400000) : NaN
328
363
  const formatDate = (value) => {
329
364
  if (!value) return '—'
330
365
  try {
331
- const d = new Date(value)
332
- const days = Math.round((d.getTime() - Date.now()) / 86400000)
333
- return d.toLocaleDateString() + (days >= 0 ? ` (in ${days} day${days === 1 ? '' : 's'})` : ' (expired)')
366
+ const days = daysUntil(value)
367
+ return new Date(value).toLocaleDateString() + (days >= 0 ? ` (in ${days} day${days === 1 ? '' : 's'})` : ' (expired)')
334
368
  } catch (e) { return String(value) }
335
369
  }
336
370
  const osIcon = (os) => {
@@ -351,12 +385,12 @@ document.addEventListener('DOMContentLoaded', function() {
351
385
  }
352
386
 
353
387
  const render = (status) => {
354
- if (current.address !== (status || {}).address || current.phase !== (status || {}).phase) {
388
+ if (current.connectUrl !== (status || {}).connectUrl || current.phase !== (status || {}).phase) {
355
389
  copyManual.hidden = true
356
390
  copyStatus.textContent = ''
357
391
  }
358
392
  current = status || {}
359
- copyButton.disabled = copying || current.phase !== 'ready' || !current.address
393
+ copyButton.disabled = copying || current.phase !== 'ready' || !current.connectUrl
360
394
  const phase = current.phase && current.phase !== 'setup' ? current.phase : 'starting'
361
395
  const on = !!current.enabled && phase !== 'off' && phase !== 'setup'
362
396
  switchEl.dataset.on = on ? 'true' : 'false'
@@ -373,8 +407,12 @@ document.addEventListener('DOMContentLoaded', function() {
373
407
  const link = document.getElementById('anywhere-approval-link')
374
408
  const url = current.approval && current.approval.url
375
409
  link.href = url || 'https://login.tailscale.com/admin/dns'
376
- if (current.approval && current.approval.text) {
377
- document.getElementById('anywhere-approval-text').textContent = current.approval.text
410
+ document.getElementById('anywhere-approval-text').textContent = (current.approval && current.approval.text) || ''
411
+ // one browser trip: the approval follows sign-in without another click; the button stays for a blocked popup
412
+ if (url && approvalOpened !== url) {
413
+ approvalOpened = url
414
+ try { sessionStorage.setItem('anywhere-approval-opened', url) } catch (_) { /* same */ }
415
+ openExternal(url)
378
416
  }
379
417
  }
380
418
  if (phase === 'error') {
@@ -384,9 +422,10 @@ document.addEventListener('DOMContentLoaded', function() {
384
422
  if (phase === 'ready') {
385
423
  const qr = document.getElementById('anywhere-qr')
386
424
  if (current.qr && qr.getAttribute('src') !== current.qr) qr.src = current.qr
387
- const address = document.getElementById('anywhere-address')
388
- address.textContent = (current.address || '').replace(/^https?:\/\//, '')
389
- address.href = current.address || '#'
425
+ // the same link the QR carries; its fragment holds the address and account, so the text stops at the hash
426
+ const link = document.getElementById('anywhere-address')
427
+ link.textContent = (current.connectUrl || '').replace(/^https?:\/\//, '').replace(/#.*$/, '#…')
428
+ link.href = current.connectUrl || '#'
390
429
  const email = current.account && current.account.email ? current.account.email : ''
391
430
  document.getElementById('anywhere-account').textContent = email
392
431
  for (const el of document.querySelectorAll('.anywhere-account-inline')) el.textContent = email
@@ -399,6 +438,16 @@ document.addEventListener('DOMContentLoaded', function() {
399
438
  waiting.classList.remove('joined')
400
439
  waiting.innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for another device to join…`
401
440
  }
441
+ // apps, devices and details only once a device has ever joined; until then the page is the onboarding card
442
+ document.getElementById('anywhere-details').hidden = peers.length === 0
443
+ const warning = document.getElementById('anywhere-expiry-warning')
444
+ const daysLeft = daysUntil(current.keyExpiry)
445
+ if (Number.isFinite(daysLeft) && daysLeft <= 14) {
446
+ warning.hidden = false
447
+ warning.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i> Your Tailscale key expires ${esc(formatDate(current.keyExpiry))}. After that, devices can't connect until you sign in again here. <a href='https://login.tailscale.com/admin/machines' target='_blank' rel='noopener'>Disable key expiry</a> for <b>${esc(current.dnsName ? current.dnsName.split('.')[0] : 'pinokio')}</b> in the Tailscale admin console.`
448
+ } else {
449
+ warning.hidden = true
450
+ }
402
451
  const apps = Array.isArray(current.apps) ? current.apps : []
403
452
  document.getElementById('anywhere-apps').innerHTML = apps.length === 0
404
453
  ? `<p class='anywhere-muted'>No app is running right now. Launch one and it will show up here.</p>`
@@ -412,9 +461,7 @@ document.addEventListener('DOMContentLoaded', function() {
412
461
  </div>`).join('')
413
462
  const serve = Array.isArray(current.serve) ? current.serve : []
414
463
  document.getElementById('anywhere-ports').innerHTML = serve.length === 0 ? '—' : serve.map((entry) => `${esc(entry.name || (entry.port === 443 ? 'Pinokio' : ''))} <a class='remote' href='${esc(entry.url)}' target='_blank' rel='noopener'>${esc(entry.url)}</a> → ${esc(entry.target)}`).join('<br>')
415
- document.getElementById('anywhere-peers').innerHTML = peers.length === 0
416
- ? `<p class='anywhere-muted'>No other devices yet. Sign in to Tailscale on another device with the same account.</p>`
417
- : peers.map((peer) => `
464
+ document.getElementById('anywhere-peers').innerHTML = peers.map((peer) => `
418
465
  <div class='anywhere-row'>
419
466
  <span class='anywhere-dot ${peer.online ? 'online' : ''}'></span>
420
467
  <i class='${osIcon(peer.os)}'></i>
@@ -430,6 +477,56 @@ document.addEventListener('DOMContentLoaded', function() {
430
477
  }
431
478
  }
432
479
 
480
+ // Your other Pinokios: only on a loopback host, refreshed with each status poll while ready
481
+ const others = document.getElementById('anywhere-others')
482
+ let othersBusy = false
483
+ let othersNote = '' // the last failed action, shown until the next one
484
+ const renderOthers = (peers) => {
485
+ if (!others) return
486
+ others.innerHTML = (othersNote ? `<p class='anywhere-error'>${esc(othersNote)}</p>` : '') + (peers.length === 0
487
+ ? `<p class='anywhere-muted'>No other Pinokio with Remote on is online.</p>`
488
+ : peers.map((peer) => `
489
+ <div class='anywhere-row anywhere-other' data-peer='${esc(peer.name)}'>
490
+ <i class='fa-solid fa-desktop'></i>
491
+ <div class='grow'>
492
+ <div class='title'>${esc(peer.name)} <a class='remote' href='/anywhere/peers/${encodeURIComponent(peer.name)}/open?port=443&to=%2F' target='_blank' rel='noopener'>Open Pinokio</a></div>
493
+ ${(peer.apps || []).length === 0 ? `<div class='sub'>no app running</div>` : peer.apps.map((app) => `
494
+ <div class='sub'>${esc(app.name)} ${app.open ? `<a class='remote' href='${esc(app.open)}' target='_blank' rel='noopener'>Open</a>` : `<span class='anywhere-badge'>${esc(app.state || 'starting')}</span>`} · <button type='button' class='anywhere-linkbtn' data-stop='${esc(app.id)}'>Stop</button></div>`).join('')}
495
+ </div>
496
+ </div>`).join(''))
497
+ }
498
+ const refreshOthers = async () => {
499
+ if (!others || othersBusy || current.phase !== 'ready') return
500
+ othersBusy = true
501
+ try {
502
+ const response = await fetch('/anywhere/peers', { cache: 'no-store' })
503
+ const answer = await response.json().catch(() => ({}))
504
+ if (!response.ok) throw new Error(answer.error || `HTTP ${response.status}`)
505
+ renderOthers(answer.peers || [])
506
+ } catch (e) {
507
+ others.innerHTML = `<p class='anywhere-muted'>Could not look for other Pinokios: ${esc(e.message)}</p>`
508
+ } finally {
509
+ othersBusy = false
510
+ }
511
+ }
512
+ if (others) {
513
+ others.addEventListener('click', async (event) => {
514
+ const button = event.target.closest('button[data-stop]')
515
+ if (!button) return
516
+ const peer = button.closest('[data-peer]').dataset.peer
517
+ button.disabled = true
518
+ othersNote = ''
519
+ try {
520
+ const response = await fetch(`/anywhere/peers/${encodeURIComponent(peer)}/apps/stop`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: button.dataset.stop }) })
521
+ const answer = await response.json().catch(() => ({}))
522
+ if (!response.ok) othersNote = `Could not stop it on ${peer}: ${answer.error || `HTTP ${response.status}`}`
523
+ } catch (e) {
524
+ othersNote = `Could not stop it on ${peer}: ${e.message}`
525
+ }
526
+ await refreshOthers()
527
+ })
528
+ }
529
+
433
530
  const poll = async () => {
434
531
  try {
435
532
  const response = await fetch('/anywhere/status', { cache: 'no-store' })
@@ -438,6 +535,7 @@ document.addEventListener('DOMContentLoaded', function() {
438
535
  } catch (e) {
439
536
  render(Object.assign({}, current, { phase: 'error', error: e.message }))
440
537
  }
538
+ refreshOthers()
441
539
  const phase = current.phase || ''
442
540
  const delay = (phase === 'ready' || phase === 'off') ? 5000 : 2000
443
541
  setTimeout(poll, delay)
@@ -485,6 +583,9 @@ document.addEventListener('DOMContentLoaded', function() {
485
583
  openExternal(link.href)
486
584
  }
487
585
  })
586
+ for (const input of document.querySelectorAll('input[name=anywhere-device]')) {
587
+ input.addEventListener('change', () => { document.getElementById('anywhere-connect').dataset.device = input.value })
588
+ }
488
589
  switchEl.addEventListener('click', () => {
489
590
  if (switchEl.dataset.on === 'true') {
490
591
  act('stop')
@@ -494,6 +595,7 @@ document.addEventListener('DOMContentLoaded', function() {
494
595
  })
495
596
 
496
597
  render(current)
598
+ refreshOthers()
497
599
  setTimeout(poll, 1500)
498
600
  })()
499
601
  </script>
@@ -8,10 +8,11 @@ const { JSDOM, VirtualConsole } = require('jsdom')
8
8
  const filename = path.resolve(__dirname, '../server/views/anywhere.ejs')
9
9
  const template = fs.readFileSync(filename, 'utf8')
10
10
  const address = 'https://pinokio-1.tail123.ts.net'
11
+ const link = 'https://pinokio.co/anywhere/connect#u=' + encodeURIComponent(address) + '&a=me%40example.com'
11
12
  const tick = () => new Promise((resolve) => setImmediate(resolve))
12
13
 
13
14
  function createHarness(t, options = {}) {
14
- const state = { status: { phase: 'ready', enabled: true, address }, timers: [], opened: [] }
15
+ const state = { status: { phase: 'ready', enabled: true, address, connectUrl: link }, timers: [], opened: [] }
15
16
  Object.assign(state.status, options.status)
16
17
  const html = ejs.render(template, {
17
18
  theme: 'light', agent: options.agent || 'browser', status: state.status
@@ -56,21 +57,21 @@ for (const agent of ['browser', 'electron']) {
56
57
  })
57
58
  h.button.focus()
58
59
  h.button.click()
59
- assert.deepEqual(copied, [address], 'clipboard is invoked synchronously in the click')
60
+ assert.deepEqual(copied, [link], 'clipboard is invoked synchronously in the click')
60
61
  assert.equal(h.button.disabled, true)
61
62
  h.button.click()
62
63
  await tick()
63
- assert.deepEqual(copied, [address], 'pending copies are not duplicated')
64
- assert.equal(h.feedback.textContent, 'URL copied.')
64
+ assert.deepEqual(copied, [link], 'pending copies are not duplicated')
65
+ assert.equal(h.feedback.textContent, 'Link copied.')
65
66
  assert.equal(h.manual.hidden, true)
66
67
  assert.equal(h.button.disabled, false)
67
68
  assert.equal(h.document.activeElement, h.button)
68
69
  assert.deepEqual(h.state.opened, [])
69
- assert.equal(h.document.getElementById('anywhere-address').getAttribute('href'), address)
70
+ assert.equal(h.document.getElementById('anywhere-address').getAttribute('href'), link)
70
71
  assert.equal(h.feedback.getAttribute('role'), 'status')
71
72
  if (agent === 'electron') {
72
73
  h.document.getElementById('anywhere-address').click()
73
- assert.deepEqual(h.state.opened, [[address + '/', '_blank', 'browser']])
74
+ assert.deepEqual(h.state.opened, [[link, '_blank', 'browser']])
74
75
  }
75
76
  })
76
77
  }
@@ -99,8 +100,8 @@ for (const scenario of ['insecure LAN', 'denied', 'synchronous failure', 'electr
99
100
  h.button.focus()
100
101
  h.button.click()
101
102
  await tick()
102
- assert.deepEqual(copied, [address])
103
- assert.equal(h.feedback.textContent, 'URL copied.')
103
+ assert.deepEqual(copied, [link])
104
+ assert.equal(h.feedback.textContent, 'Link copied.')
104
105
  assert.equal(h.manual.hidden, true)
105
106
  assert.equal(h.document.activeElement, h.button)
106
107
  assert.deepEqual(h.state.opened, [])
@@ -119,17 +120,17 @@ for (const failure of ['false', 'undefined', 'throws', 'unavailable']) {
119
120
  h.button.click()
120
121
  await tick()
121
122
  assert.equal(h.manual.hidden, false)
122
- assert.equal(h.input.value, address)
123
+ assert.equal(h.input.value, link)
123
124
  assert.equal(h.input.selectionStart, 0)
124
- assert.equal(h.input.selectionEnd, address.length)
125
+ assert.equal(h.input.selectionEnd, link.length)
125
126
  assert.equal(h.document.activeElement, h.input)
126
127
  assert.match(h.feedback.textContent, /Touch and hold.*Ctrl\+C \/ ⌘C/)
127
- assert.doesNotMatch(h.feedback.textContent, /URL copied/)
128
+ assert.doesNotMatch(h.feedback.textContent, /Link copied/)
128
129
  assert.equal(h.button.disabled, false)
129
130
  await h.poll()
130
131
  assert.equal(h.manual.hidden, false, 'routine polling preserves the manual copy field')
131
- assert.equal(h.input.selectionEnd, address.length)
132
- h.state.status.address = 'https://renamed.tail123.ts.net'
132
+ assert.equal(h.input.selectionEnd, link.length)
133
+ h.state.status.connectUrl = 'https://pinokio.co/anywhere/connect#u=https%3A%2F%2Frenamed.tail123.ts.net'
133
134
  await h.poll()
134
135
  assert.equal(h.manual.hidden, true, 'a changed URL clears the stale manual field')
135
136
  assert.equal(h.feedback.textContent, '')
@@ -138,7 +139,7 @@ for (const failure of ['false', 'undefined', 'throws', 'unavailable']) {
138
139
 
139
140
  test('Remote does not copy a placeholder or an unavailable address', async (t) => {
140
141
  const h = createHarness(t, {
141
- status: { address: '' },
142
+ status: { address: '', connectUrl: '' },
142
143
  clipboard: { writeText: () => assert.fail('no URL to copy') },
143
144
  execCommand: () => assert.fail('no URL to copy')
144
145
  })
@@ -146,7 +147,7 @@ test('Remote does not copy a placeholder or an unavailable address', async (t) =
146
147
  h.button.click()
147
148
  await tick()
148
149
  assert.equal(h.feedback.textContent, '')
149
- h.state.status.address = address
150
+ h.state.status.connectUrl = link
150
151
  await h.poll()
151
152
  assert.equal(h.button.disabled, false)
152
153
  h.state.status.phase = 'off'
@@ -165,8 +166,8 @@ test('Remote restores keyboard focus after disabling the button during modern co
165
166
  assert.equal(h.document.activeElement, h.button)
166
167
  })
167
168
 
168
- test('Remote ignores a pending copy result if the address changes or Remote stops', async (t) => {
169
- for (const change of [{ address: 'https://new.tail123.ts.net' }, { phase: 'off' }]) {
169
+ test('Remote ignores a pending copy result if the link changes or Remote stops', async (t) => {
170
+ for (const change of [{ connectUrl: 'https://pinokio.co/anywhere/connect#u=https%3A%2F%2Fnew.tail123.ts.net' }, { phase: 'off' }]) {
170
171
  let rejectCopy
171
172
  const h = createHarness(t, {
172
173
  clipboard: { writeText: () => new Promise((resolve, reject) => { rejectCopy = reject }) },
@@ -0,0 +1,135 @@
1
+ const assert = require('node:assert/strict')
2
+ const http = require('node:http')
3
+ const test = require('node:test')
4
+ const { once } = require('node:events')
5
+ const WebSocket = require('ws')
6
+ const { Forwarders } = require('../kernel/anywhere/forward')
7
+
8
+ // Forwarders (spec/requirements/pinokio-anywhere.md, Other Pinokios; the
9
+ // API spec, Peers): one loopback port relaying to one peer and port. The
10
+ // peer here is a plain local server, so the TLS-through-proxy layer is
11
+ // replaced by the http scheme and no agent; what is checked is the relay.
12
+
13
+ async function peer(t) {
14
+ const seen = []
15
+ const server = http.createServer((req, res) => {
16
+ seen.push({ url: req.url, headers: req.headers, method: req.method })
17
+ let body = ''
18
+ req.on('data', (chunk) => { body += chunk })
19
+ req.on('end', () => {
20
+ res.writeHead(200, { 'Content-Type': 'application/json' })
21
+ res.end(JSON.stringify({ url: req.url, host: req.headers.host, forwarded: req.headers['x-pinokio-forwarded'] || null, peer: req.headers['x-pinokio-peer'] || null, body }))
22
+ })
23
+ })
24
+ const wss = new WebSocket.Server({ server })
25
+ wss.on('connection', (socket, req) => {
26
+ socket.on('message', (message) => socket.send(`echo:${message} forwarded=${req.headers['x-pinokio-forwarded'] || 'none'} peer=${req.headers['x-pinokio-peer'] || 'none'}`))
27
+ })
28
+ server.listen(0, '127.0.0.1')
29
+ await once(server, 'listening')
30
+ t.after(() => new Promise((resolve) => { wss.close(); server.close(resolve); server.closeAllConnections() }))
31
+ return { port: server.address().port, seen }
32
+ }
33
+
34
+ const get = (port, path, headers = {}) => new Promise((resolve, reject) => {
35
+ http.get({ host: '127.0.0.1', port, path, headers }, (res) => {
36
+ let body = ''
37
+ res.on('data', (chunk) => { body += chunk })
38
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
39
+ }).on('error', reject)
40
+ })
41
+
42
+ test('a forwarder relays requests with the forwarded header, the peer as Host, and no trusted peer headers', async (t) => {
43
+ const target = await peer(t)
44
+ const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
45
+ t.after(() => forwarders.stopAll())
46
+ const local = await forwarders.open('127.0.0.1', target.port)
47
+ assert.equal(await forwarders.open('127.0.0.1', target.port), local, 'the same peer and port reuse the forwarder')
48
+ const answer = await get(local, '/ui/?a=1', { 'X-Pinokio-Peer': '1', 'X-Pinokio-Source-Host': 'evil', 'X-Pinokio-Source-Proto': 'https' })
49
+ assert.equal(answer.status, 200)
50
+ const body = JSON.parse(answer.body)
51
+ assert.equal(body.url, '/ui/?a=1')
52
+ assert.equal(body.host, `127.0.0.1:${target.port}`, 'Host names the peer, not the loopback port')
53
+ assert.equal(body.forwarded, '1')
54
+ assert.equal(body.peer, null, 'the existing peer-trust headers never pass through')
55
+ assert.equal(target.seen[0].headers['x-pinokio-source-host'], undefined)
56
+ })
57
+
58
+ test('two opens of the same peer and port at once share one forwarder', async (t) => {
59
+ const target = await peer(t)
60
+ const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
61
+ t.after(() => forwarders.stopAll())
62
+ const [a, b] = await Promise.all([forwarders.open('127.0.0.1', target.port), forwarders.open('127.0.0.1', target.port)])
63
+ assert.ok(Number.isInteger(a) && a > 0)
64
+ assert.equal(a, b)
65
+ })
66
+
67
+ test('a forwarder answers /.pinokio/open itself with a redirect to this node\'s open route', async (t) => {
68
+ const target = await peer(t)
69
+ const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
70
+ t.after(() => forwarders.stopAll())
71
+ const local = await forwarders.open('pinokio-1.tail1.ts.net', target.port)
72
+ const answer = await get(local, '/.pinokio/open?port=43001&to=%2Fui%2F%3Fa%3D1%23view')
73
+ assert.equal(answer.status, 302)
74
+ assert.equal(answer.headers.location, 'http://127.0.0.1:42000/anywhere/peers/pinokio-1/open?port=43001&to=%2Fui%2F%3Fa%3D1%23view')
75
+ assert.equal((await get(local, '/.pinokio/open?to=%2F')).status, 400, 'a link without a port does not bounce through pinokiod')
76
+ assert.equal(target.seen.length, 0, 'never relayed')
77
+ })
78
+
79
+ test('a forwarder relays a WebSocket upgrade', async (t) => {
80
+ const target = await peer(t)
81
+ const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
82
+ t.after(() => forwarders.stopAll())
83
+ const local = await forwarders.open('127.0.0.1', target.port)
84
+ const socket = new WebSocket(`ws://127.0.0.1:${local}/socket`, { headers: { 'X-Pinokio-Peer': '1' } })
85
+ await once(socket, 'open')
86
+ socket.send('hi')
87
+ const [message] = await once(socket, 'message')
88
+ assert.equal(String(message), 'echo:hi forwarded=1 peer=none', 'the forwarded header is added and the trust header dropped on the upgrade path too')
89
+ socket.close()
90
+ })
91
+
92
+ test('a peer that cannot be reached answers 502, and stopAll closes every forwarder, an open WebSocket included', async (t) => {
93
+ const target = await peer(t)
94
+ const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
95
+ const dead = await forwarders.open('127.0.0.1', 1)
96
+ const answer = await get(dead, '/')
97
+ assert.equal(answer.status, 502)
98
+ assert.match(answer.body, /Could not reach 127\.0\.0\.1/)
99
+ const live = await forwarders.open('127.0.0.1', target.port)
100
+ const socket = new WebSocket(`ws://127.0.0.1:${live}/socket`)
101
+ await once(socket, 'open')
102
+ const closed = once(socket, 'close')
103
+ const stopped = forwarders.stopAll()
104
+ await Promise.race([stopped, new Promise((_, reject) => setTimeout(() => reject(new Error('stopAll hung on the open WebSocket')), 2000))])
105
+ await closed
106
+ await assert.rejects(get(live, '/'), /ECONNREFUSED/)
107
+ })
108
+
109
+ test('the https scheme refuses to forward while the daemon is down', () => {
110
+ const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000 })
111
+ assert.throws(() => forwarders.agent(), /Remote is not running/)
112
+ })
113
+
114
+ test('a request to a peer returns status and body, and the deadline covers the connection itself', async (t) => {
115
+ const target = await peer(t)
116
+ const plain = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
117
+ const answer = await plain.request(`http://127.0.0.1:${target.port}/anywhere/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"id":"x"}' })
118
+ assert.equal(answer.status, 200)
119
+ assert.equal(JSON.parse(answer.body).body, '{"id":"x"}')
120
+ const silent = http.createServer(() => { /* never answers */ })
121
+ silent.listen(0, '127.0.0.1')
122
+ await once(silent, 'listening')
123
+ t.after(() => { silent.closeAllConnections(); silent.close() })
124
+ await assert.rejects(plain.request(`http://127.0.0.1:${silent.address().port}/`, { timeoutMs: 200 }), /no answer in 0 s/)
125
+ // a proxy that accepts and never answers the CONNECT, through the real agent: the deadline
126
+ // still fires there, where a socket timeout would not have started yet
127
+ const held = []
128
+ const stuckProxy = require('node:net').createServer((socket) => { held.push(socket) /* never answers */ })
129
+ stuckProxy.listen(0, '127.0.0.1')
130
+ await once(stuckProxy, 'listening')
131
+ t.after(() => { for (const socket of held) socket.destroy(); stuckProxy.close() })
132
+ const forwarders = new Forwarders({ proxyUrl: () => `http://127.0.0.1:${stuckProxy.address().port}`, serverPort: () => 42000 })
133
+ t.after(() => forwarders.stopAll())
134
+ await assert.rejects(forwarders.request('https://peer.tail1.ts.net/anywhere/status', { timeoutMs: 300 }), /no answer in 0 s/)
135
+ })