pinokiod 8.2.29 → 8.2.30

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.
@@ -215,7 +215,7 @@ class Anywhere {
215
215
  if (!this.config.enabled) return false
216
216
  if (!(await this.installed())) {
217
217
  if (automatic) return false
218
- throw new Error('Install the matching Tailscale daemon and CLI through Anywhere setup.')
218
+ throw new Error('Install the matching Tailscale daemon and CLI through Remote setup.')
219
219
  }
220
220
  if (this.stopping) return false
221
221
  this.error = null
@@ -37,7 +37,7 @@ function assignPorts(map, keys) {
37
37
  for (const key of keys) {
38
38
  if (Number.isInteger(out[key])) continue
39
39
  while (used.has(next)) next += 1
40
- if (next > LAST_PORT) throw new Error('Pinokio Anywhere has no free remote ports left')
40
+ if (next > LAST_PORT) throw new Error('Remote has no free remote ports left')
41
41
  out[key] = next
42
42
  used.add(next)
43
43
  changed = true
@@ -274,7 +274,7 @@ report_errors: false`)
274
274
  }
275
275
 
276
276
  if (this.kernel.anywhere) {
277
- ondata({ raw: `stopping Pinokio Anywhere before replacing Miniforge...\r\n` })
277
+ ondata({ raw: `stopping Remote before replacing Miniforge...\r\n` })
278
278
  await this.kernel.anywhere.stop()
279
279
  }
280
280
 
@@ -275,7 +275,7 @@ module.exports = {
275
275
  ]
276
276
  return {
277
277
  icon: "fa-solid fa-mobile-screen",
278
- title: "Pinokio Anywhere",
278
+ title: "Remote",
279
279
  description: "Reach this Pinokio and its apps from your phone or another computer over a private Tailscale network",
280
280
  requirements,
281
281
  conda_requirements: [
@@ -7,7 +7,7 @@ const run = promisify(execFile)
7
7
  const VERSION = '1.104.1'
8
8
 
9
9
  class Tailscaled {
10
- description = "Installs the Tailscale daemon for Pinokio Anywhere: reach this Pinokio and its apps from your phone or another computer over a private network."
10
+ description = "Installs the Tailscale daemon for Remote: reach this Pinokio and its apps from your phone or another computer over a private network."
11
11
  cmd() {
12
12
  return `tailscaled=${VERSION} tailscale=${VERSION}`
13
13
  }
@@ -67,7 +67,7 @@ class Tailscaled {
67
67
  })
68
68
  }
69
69
  async uninstall(req, ondata) {
70
- ondata({ raw: "stopping Pinokio Anywhere\r\n" })
70
+ ondata({ raw: "stopping Remote\r\n" })
71
71
  if (this.kernel.anywhere && typeof this.kernel.anywhere.stop === "function") {
72
72
  await this.kernel.anywhere.stop()
73
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.2.29",
3
+ "version": "8.2.30",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -14389,7 +14389,7 @@ class Server {
14389
14389
  const anywhereAction = (label, fn) => ex(async (req, res) => {
14390
14390
  const anywhere = this.kernel.anywhere
14391
14391
  if (!anywhere) {
14392
- res.status(500).json({ success: false, error: "Pinokio Anywhere is not available" })
14392
+ res.status(500).json({ success: false, error: "Remote is not available" })
14393
14393
  return
14394
14394
  }
14395
14395
  try {
@@ -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 { 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>
@@ -246,7 +271,6 @@ document.addEventListener('DOMContentLoaded', function() {
246
271
  <dt>Daemon</dt><dd id='anywhere-version'></dd>
247
272
  </dl>
248
273
  <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
274
  <button class='anywhere-btn secondary' data-action='stop'><i class="fa-solid fa-power-off"></i> Turn off</button>
251
275
  </p>
252
276
  </div>
@@ -274,15 +298,18 @@ document.addEventListener('DOMContentLoaded', function() {
274
298
  let current = <%- JSON.stringify(status || {}).replace(/</g, '\\u003c') %>
275
299
  let busy = false
276
300
  let copying = false
301
+ // the approval URL opened without a click, kept for the window so a reload does not open it again
302
+ let approvalOpened = null
303
+ try { approvalOpened = sessionStorage.getItem('anywhere-approval-opened') } catch (_) { /* storage unavailable: once per page load */ }
277
304
 
278
305
  copyButton.addEventListener('click', async () => {
279
- const url = current.address
306
+ const url = current.connectUrl
280
307
  if (copying || current.phase !== 'ready' || !url) return
281
308
  const previousFocus = document.activeElement
282
309
  copying = true
283
310
  copyButton.disabled = true
284
311
  copyStatus.textContent = 'Copying…'
285
- const stillCurrent = () => current.phase === 'ready' && current.address === url
312
+ const stillCurrent = () => current.phase === 'ready' && current.connectUrl === url
286
313
  let copied = false
287
314
  try {
288
315
  // Call directly from the click: Safari requires a user gesture.
@@ -295,7 +322,7 @@ document.addEventListener('DOMContentLoaded', function() {
295
322
  if (!copied) {
296
323
  copyValue.value = url
297
324
  copyManual.hidden = false
298
- copyStatus.textContent = 'Automatic copy is unavailable. Touch and hold the URL to copy it, or press Ctrl+C / ⌘C.'
325
+ copyStatus.textContent = 'Automatic copy is unavailable. Touch and hold the link to copy it, or press Ctrl+C / ⌘C.'
299
326
  try {
300
327
  copyValue.focus({ preventScroll: true })
301
328
  copyValue.select()
@@ -305,11 +332,11 @@ document.addEventListener('DOMContentLoaded', function() {
305
332
  }
306
333
  if (copied) {
307
334
  copyManual.hidden = true
308
- copyStatus.textContent = 'URL copied.'
335
+ copyStatus.textContent = 'Link copied.'
309
336
  }
310
337
  }
311
338
  copying = false
312
- copyButton.disabled = current.phase !== 'ready' || !current.address
339
+ copyButton.disabled = current.phase !== 'ready' || !current.connectUrl
313
340
  if (copied && stillCurrent() && previousFocus &&
314
341
  (document.activeElement === copyValue || document.activeElement === document.body)) {
315
342
  previousFocus.focus({ preventScroll: true })
@@ -325,12 +352,12 @@ document.addEventListener('DOMContentLoaded', function() {
325
352
  window.open(href, '_blank')
326
353
  }
327
354
  }
355
+ const daysUntil = (value) => value ? Math.round((new Date(value).getTime() - Date.now()) / 86400000) : NaN
328
356
  const formatDate = (value) => {
329
357
  if (!value) return '—'
330
358
  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)')
359
+ const days = daysUntil(value)
360
+ return new Date(value).toLocaleDateString() + (days >= 0 ? ` (in ${days} day${days === 1 ? '' : 's'})` : ' (expired)')
334
361
  } catch (e) { return String(value) }
335
362
  }
336
363
  const osIcon = (os) => {
@@ -351,12 +378,12 @@ document.addEventListener('DOMContentLoaded', function() {
351
378
  }
352
379
 
353
380
  const render = (status) => {
354
- if (current.address !== (status || {}).address || current.phase !== (status || {}).phase) {
381
+ if (current.connectUrl !== (status || {}).connectUrl || current.phase !== (status || {}).phase) {
355
382
  copyManual.hidden = true
356
383
  copyStatus.textContent = ''
357
384
  }
358
385
  current = status || {}
359
- copyButton.disabled = copying || current.phase !== 'ready' || !current.address
386
+ copyButton.disabled = copying || current.phase !== 'ready' || !current.connectUrl
360
387
  const phase = current.phase && current.phase !== 'setup' ? current.phase : 'starting'
361
388
  const on = !!current.enabled && phase !== 'off' && phase !== 'setup'
362
389
  switchEl.dataset.on = on ? 'true' : 'false'
@@ -373,8 +400,12 @@ document.addEventListener('DOMContentLoaded', function() {
373
400
  const link = document.getElementById('anywhere-approval-link')
374
401
  const url = current.approval && current.approval.url
375
402
  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
403
+ document.getElementById('anywhere-approval-text').textContent = (current.approval && current.approval.text) || ''
404
+ // one browser trip: the approval follows sign-in without another click; the button stays for a blocked popup
405
+ if (url && approvalOpened !== url) {
406
+ approvalOpened = url
407
+ try { sessionStorage.setItem('anywhere-approval-opened', url) } catch (_) { /* same */ }
408
+ openExternal(url)
378
409
  }
379
410
  }
380
411
  if (phase === 'error') {
@@ -384,9 +415,10 @@ document.addEventListener('DOMContentLoaded', function() {
384
415
  if (phase === 'ready') {
385
416
  const qr = document.getElementById('anywhere-qr')
386
417
  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 || '#'
418
+ // the same link the QR carries; its fragment holds the address and account, so the text stops at the hash
419
+ const link = document.getElementById('anywhere-address')
420
+ link.textContent = (current.connectUrl || '').replace(/^https?:\/\//, '').replace(/#.*$/, '#…')
421
+ link.href = current.connectUrl || '#'
390
422
  const email = current.account && current.account.email ? current.account.email : ''
391
423
  document.getElementById('anywhere-account').textContent = email
392
424
  for (const el of document.querySelectorAll('.anywhere-account-inline')) el.textContent = email
@@ -399,6 +431,16 @@ document.addEventListener('DOMContentLoaded', function() {
399
431
  waiting.classList.remove('joined')
400
432
  waiting.innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for another device to join…`
401
433
  }
434
+ // apps, devices and details only once a device has ever joined; until then the page is the onboarding card
435
+ document.getElementById('anywhere-details').hidden = peers.length === 0
436
+ const warning = document.getElementById('anywhere-expiry-warning')
437
+ const daysLeft = daysUntil(current.keyExpiry)
438
+ if (Number.isFinite(daysLeft) && daysLeft <= 14) {
439
+ warning.hidden = false
440
+ 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.`
441
+ } else {
442
+ warning.hidden = true
443
+ }
402
444
  const apps = Array.isArray(current.apps) ? current.apps : []
403
445
  document.getElementById('anywhere-apps').innerHTML = apps.length === 0
404
446
  ? `<p class='anywhere-muted'>No app is running right now. Launch one and it will show up here.</p>`
@@ -412,9 +454,7 @@ document.addEventListener('DOMContentLoaded', function() {
412
454
  </div>`).join('')
413
455
  const serve = Array.isArray(current.serve) ? current.serve : []
414
456
  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) => `
457
+ document.getElementById('anywhere-peers').innerHTML = peers.map((peer) => `
418
458
  <div class='anywhere-row'>
419
459
  <span class='anywhere-dot ${peer.online ? 'online' : ''}'></span>
420
460
  <i class='${osIcon(peer.os)}'></i>
@@ -485,6 +525,9 @@ document.addEventListener('DOMContentLoaded', function() {
485
525
  openExternal(link.href)
486
526
  }
487
527
  })
528
+ for (const input of document.querySelectorAll('input[name=anywhere-device]')) {
529
+ input.addEventListener('change', () => { document.getElementById('anywhere-connect').dataset.device = input.value })
530
+ }
488
531
  switchEl.addEventListener('click', () => {
489
532
  if (switchEl.dataset.on === 'true') {
490
533
  act('stop')
@@ -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,95 @@
1
+ const assert = require('node:assert/strict')
2
+ const fs = require('node:fs')
3
+ const path = require('node:path')
4
+ const test = require('node:test')
5
+ const ejs = require('ejs')
6
+ const { JSDOM, VirtualConsole } = require('jsdom')
7
+
8
+ // The Remote page's onboarding states (spec/requirements/pinokio-anywhere.md,
9
+ // Interface > Onboarding): what the ready state shows before and after a
10
+ // device joins, the key-expiry warning, and the approval that opens on its own.
11
+ const filename = path.resolve(__dirname, '../server/views/anywhere.ejs')
12
+ const template = fs.readFileSync(filename, 'utf8')
13
+ const address = 'https://pinokio.tail123.ts.net'
14
+ const connectUrl = 'https://pinokio.co/anywhere/connect#u=' + encodeURIComponent(address) + '&a=me%40example.com'
15
+ const days = (n) => new Date(Date.now() + n * 86400000).toISOString()
16
+
17
+ function render(t, status, agent = 'electron') {
18
+ const state = { status: { enabled: true, address, connectUrl, dnsName: 'pinokio.tail123.ts.net', account: { email: 'me@example.com' }, peers: [], ...status }, timers: [], opened: [] }
19
+ const html = ejs.render(template, { theme: 'light', agent, status: state.status }, { filename, includer: () => ({ template: ' ' }) })
20
+ const virtualConsole = new VirtualConsole()
21
+ virtualConsole.on('jsdomError', (error) => { throw error })
22
+ const dom = new JSDOM(html, {
23
+ url: 'http://127.0.0.1:42000/', runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole,
24
+ beforeParse(window) {
25
+ window.setTimeout = (callback, delay) => state.timers.push({ callback, delay })
26
+ window.fetch = async () => ({ ok: true, json: async () => ({ ...state.status }) })
27
+ window.open = (...args) => state.opened.push(args)
28
+ },
29
+ })
30
+ t.after(() => dom.window.close())
31
+ const document = dom.window.document
32
+ return { state, document, poll: () => state.timers.shift().callback(), text: (id) => document.getElementById(id).textContent }
33
+ }
34
+
35
+ test('before any device joins, the ready state is the connect card alone, with sign out', (t) => {
36
+ const h = render(t, { phase: 'ready', peers: [] })
37
+ const card = h.document.querySelector('[data-state=ready] .anywhere-card')
38
+ assert.equal(h.document.getElementById('anywhere-details').hidden, true, 'apps, devices and details wait for a device')
39
+ assert.ok(card.querySelector('.anywhere-signout[data-action=logout]'), 'a wrong-account sign-in is fixable during onboarding')
40
+ assert.ok(!h.document.querySelector('#anywhere-details [data-action=logout]'), 'sign out lives with the account, not in details')
41
+ assert.equal(card.querySelectorAll('ol, h3').length, 0, 'no step lists on the desk: the phone page explains Tailscale after the scan')
42
+ const connect = h.document.getElementById('anywhere-connect')
43
+ assert.equal(connect.dataset.device, 'phone', 'the QR is the default')
44
+ assert.match(connect.querySelector('[data-panel=phone]').textContent, /Scan with your phone\./)
45
+ const computer = connect.querySelector('[data-panel=computer]')
46
+ assert.match(computer.textContent, /signing in as me@example.com/)
47
+ const choose = connect.querySelector('input[value=computer]')
48
+ choose.checked = true
49
+ choose.dispatchEvent(new h.document.defaultView.Event('change'))
50
+ assert.equal(connect.dataset.device, 'computer', 'the segmented control switches the panel')
51
+ const link = computer.querySelector('#anywhere-address')
52
+ assert.equal(link.getAttribute('href'), connectUrl, 'the computer path is the same link the QR carries')
53
+ assert.equal(link.textContent, 'pinokio.co/anywhere/connect#…', 'the fragment is not spelled out')
54
+ assert.equal(h.text('anywhere-account'), 'me@example.com')
55
+ assert.match(h.text('anywhere-waiting'), /Waiting for another device/)
56
+ assert.equal(h.document.getElementById('anywhere-expiry-warning').hidden, true)
57
+ })
58
+
59
+ test('once a device has joined, the rest of the page appears, online or not', async (t) => {
60
+ const h = render(t, { phase: 'ready', peers: [{ name: 'phone', os: 'iOS', online: false }] })
61
+ assert.equal(h.document.getElementById('anywhere-details').hidden, false)
62
+ assert.match(h.text('anywhere-waiting'), /No other devices are online/)
63
+ h.state.status.peers = []
64
+ h.poll(); await new Promise(setImmediate)
65
+ assert.equal(h.document.getElementById('anywhere-details').hidden, true, 'the page follows the payload')
66
+ })
67
+
68
+ test('a key expiring within two weeks is warned about on the desk, with the admin console link', (t) => {
69
+ const soon = render(t, { phase: 'ready', keyExpiry: days(10) })
70
+ const warning = soon.document.getElementById('anywhere-expiry-warning')
71
+ assert.equal(warning.hidden, false)
72
+ assert.match(warning.textContent, /expires .*\(in 10 days\)/)
73
+ assert.match(warning.textContent, /Disable key expiry for pinokio/)
74
+ assert.equal(warning.querySelector('a').getAttribute('href'), 'https://login.tailscale.com/admin/machines')
75
+ assert.equal(render(t, { phase: 'ready', keyExpiry: days(60) }).document.getElementById('anywhere-expiry-warning').hidden, true)
76
+ assert.equal(render(t, { phase: 'ready' }).document.getElementById('anywhere-expiry-warning').hidden, true, 'no expiry reported, no warning')
77
+ })
78
+
79
+ test('the approval opens in the external browser once per URL, without a click, and the button stays', async (t) => {
80
+ const approval = { url: 'https://login.tailscale.com/f/serve?node=1', text: 'Enable HTTPS certificates for your tailnet to continue.' }
81
+ const h = render(t, { phase: 'https', approval })
82
+ assert.deepEqual(h.state.opened, [[approval.url, '_blank', 'browser']])
83
+ assert.equal(h.document.getElementById('anywhere-approval-link').getAttribute('href'), approval.url)
84
+ assert.equal(h.text('anywhere-approval-text'), approval.text, "Tailscale's own text is the detail line")
85
+ h.poll(); await new Promise(setImmediate)
86
+ assert.equal(h.state.opened.length, 1, 'a poll with the same URL does not open it again')
87
+ assert.equal(h.document.defaultView.sessionStorage.getItem('anywhere-approval-opened'), approval.url, 'remembered for the window, so a reload does not open it again')
88
+ h.state.status.approval = { url: 'https://login.tailscale.com/f/serve?node=2', text: '' }
89
+ h.poll(); await new Promise(setImmediate)
90
+ assert.equal(h.state.opened.length, 2, 'a fresh approval URL opens once more')
91
+ assert.equal(h.text('anywhere-approval-text'), '')
92
+ const login = render(t, { phase: 'login', loginUrl: 'https://login.tailscale.com/a/1' })
93
+ assert.match(login.document.querySelector('[data-state=login]').textContent, /may be asked to approve one thing/)
94
+ assert.deepEqual(login.state.opened, [], 'sign-in itself still waits for the click')
95
+ })