pinokiod 8.2.28 → 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.
@@ -46,6 +46,7 @@ class Anywhere {
46
46
  this.starting = null
47
47
  this.lastStatus = null
48
48
  this.lastStatusAt = 0
49
+ this.statusQuery = null
49
50
  this.lastServe = undefined // last serve config we pushed (undefined = unknown)
50
51
  this.lastServeError = null
51
52
  this.portMap = null // app key -> stable remote port (ports.json)
@@ -53,6 +54,8 @@ class Anywhere {
53
54
  this.feature = null // cached query-feature result
54
55
  this.featureAt = 0
55
56
  this.error = null
57
+ this.statusError = null
58
+ this.featureError = null
56
59
  this.login = null
57
60
  this.stopping = null
58
61
  }
@@ -212,10 +215,13 @@ class Anywhere {
212
215
  if (!this.config.enabled) return false
213
216
  if (!(await this.installed())) {
214
217
  if (automatic) return false
215
- 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.')
216
219
  }
217
220
  if (this.stopping) return false
218
221
  this.error = null
222
+ this.statusError = null
223
+ this.featureError = null
224
+ this.statusQuery = null
219
225
  if (this.api) this.api.cancel()
220
226
  if (this.syncing) await this.syncing.catch(() => {})
221
227
  const forceLogin = !!(this.login && this.login.error)
@@ -282,7 +288,7 @@ class Anywhere {
282
288
  this.lastServeError = null
283
289
  this.lastStatusAt = 0
284
290
  const status = await this.refreshStatus(true)
285
- if (!status) throw new Error(this.error)
291
+ if (!status) throw new Error(this.error || this.statusError || 'Could not read Tailscale status.')
286
292
  if (status.BackendState !== 'Running') this.requestLogin(forceLogin)
287
293
  return true
288
294
  }
@@ -345,6 +351,7 @@ class Anywhere {
345
351
  return inspection ? this.ownedPid(inspection, execFileSync(inspection.file, inspection.args, inspection.options)) : null
346
352
  }
347
353
  async _stop() {
354
+ this.statusQuery = null
348
355
  if (this.api) this.api.cancel()
349
356
  if (this.starting) await this.starting.catch(() => {})
350
357
  if (this.syncing) await this.syncing.catch(() => {})
@@ -359,6 +366,8 @@ class Anywhere {
359
366
  await sleep(100)
360
367
  }
361
368
  this.error = null
369
+ this.statusError = null
370
+ this.featureError = null
362
371
  this.lastServeError = null
363
372
  this.child = null
364
373
  this.login = null
@@ -414,10 +423,13 @@ class Anywhere {
414
423
  }
415
424
  async logout() {
416
425
  if (!(await this.assertOwnership()) || this.stopping) return false
426
+ this.statusQuery = null
417
427
  if (this.api) this.api.cancel()
418
428
  this.login = null
419
429
  await this.client().logout()
420
430
  this.error = null
431
+ this.statusError = null
432
+ this.featureError = null
421
433
  this.lastStatus = null
422
434
  this.lastStatusAt = 0
423
435
  this.lastServe = undefined
@@ -429,24 +441,35 @@ class Anywhere {
429
441
 
430
442
  // ---------- status ----------
431
443
  async refreshStatus(force) {
444
+ if (!force && this.statusQuery && this.statusQuery.api === this.api) {
445
+ return this.statusQuery.promise
446
+ }
432
447
  const now = Date.now()
433
448
  if (!force && this.lastStatus && now - this.lastStatusAt < STATUS_CACHE_MS) {
434
449
  return this.lastStatus
435
450
  }
436
451
  const api = this.client()
437
- try {
438
- const status = await api.status()
439
- if (this.api !== api) return null
440
- this.lastStatus = status
441
- this.lastStatusAt = Date.now()
442
- return status
443
- } catch (e) {
444
- if (this.api !== api) return null
445
- if (!e.cancelled) this.error = e.message
446
- this.lastStatus = null
447
- this.lastStatusAt = Date.now()
448
- return null
449
- }
452
+ const query = { api, promise: null }
453
+ this.statusQuery = query
454
+ query.promise = (async () => {
455
+ try {
456
+ const status = await api.status()
457
+ if (this.api !== api || this.statusQuery !== query) return null
458
+ this.lastStatus = status
459
+ this.statusError = null
460
+ this.lastStatusAt = Date.now()
461
+ return status
462
+ } catch (e) {
463
+ if (this.api !== api || this.statusQuery !== query) return null
464
+ if (!e.cancelled) this.statusError = e.message
465
+ this.lastStatus = null
466
+ this.lastStatusAt = Date.now()
467
+ return null
468
+ } finally {
469
+ if (this.statusQuery === query) this.statusQuery = null
470
+ }
471
+ })()
472
+ return query.promise
450
473
  }
451
474
  dnsNameFrom(status) {
452
475
  if (!status || !status.Self || !status.Self.DNSName) return null
@@ -457,6 +480,7 @@ class Anywhere {
457
480
  if (!status || status.BackendState !== 'Running') return null
458
481
  if (Array.isArray(status.CertDomains) && status.CertDomains.length > 0) {
459
482
  this.feature = { Complete: true }
483
+ this.featureError = null
460
484
  return this.feature
461
485
  }
462
486
  if (this.feature && Date.now() - this.featureAt < FEATURE_QUERY_CACHE_MS) {
@@ -470,9 +494,10 @@ class Anywhere {
470
494
  })
471
495
  }
472
496
  this.feature = await this.featureQuery
497
+ this.featureError = null
473
498
  this.featureAt = Date.now()
474
499
  } catch (e) {
475
- if (!e.cancelled) this.error = e.message
500
+ if (!e.cancelled) this.featureError = e.message
476
501
  this.feature = null
477
502
  this.featureAt = 0
478
503
  } finally {
@@ -600,9 +625,10 @@ class Anywhere {
600
625
  if (!Array.isArray(menu) || !this.isNodeHost(host)) return menu
601
626
  // Confirm new or changed routes on first render; refresh checks unchanged ones.
602
627
  const apps = this.appTargets()
603
- if (this.syncing || this.lastServe === undefined || this.lastServeError || apps.length !== this.targets.length ||
604
- apps.some((app, i) => app.key !== this.targets[i].key || app.local !== this.targets[i].local || app.origin !== this.targets[i].origin)) {
605
- await this.sync()
628
+ const targetsChanged = apps.length !== this.targets.length ||
629
+ apps.some((app, i) => app.key !== this.targets[i].key || app.local !== this.targets[i].local || app.origin !== this.targets[i].origin)
630
+ if (this.syncing || this.lastServe === undefined || this.lastServeError || targetsChanged) {
631
+ await this.sync({ recheck: targetsChanged })
606
632
  }
607
633
  const rewrite = (items) => {
608
634
  for (const item of items) {
@@ -623,10 +649,10 @@ class Anywhere {
623
649
  }
624
650
  // Regenerates the Serve config from the apps' published URLs. Called from
625
651
  // kernel.refresh() (same trigger as router.update()) and after start.
626
- async sync() {
627
- // Concurrent callers share one operation, with a further pass for new work.
652
+ async sync({ recheck = true } = {}) {
653
+ // Reads join the pending work; only changed targets request another pass.
628
654
  if (this.syncing) {
629
- this.syncAgain = true
655
+ if (recheck) this.syncAgain = true
630
656
  return this.syncing
631
657
  }
632
658
  this.syncing = (async () => {
@@ -707,8 +733,8 @@ class Anywhere {
707
733
  if (this.stopping || !this.config.enabled) return inactive()
708
734
  if (!status) {
709
735
  return Object.assign(base, {
710
- phase: this.error ? 'error' : 'starting',
711
- error: this.error,
736
+ phase: this.error || this.statusError ? 'error' : 'starting',
737
+ error: this.error || this.statusError,
712
738
  running: false,
713
739
  })
714
740
  }
@@ -761,7 +787,7 @@ class Anywhere {
761
787
  if (this.stopping || !this.config.enabled) return inactive()
762
788
  if (!feature) {
763
789
  out.phase = 'error'
764
- out.error = this.error || 'Could not query HTTPS approval.'
790
+ out.error = this.error || this.featureError || 'Could not query HTTPS approval.'
765
791
  return out
766
792
  }
767
793
  if (!feature.Complete) {
@@ -772,10 +798,10 @@ class Anywhere {
772
798
  }
773
799
  return out
774
800
  }
775
- await this.sync()
801
+ await this.sync({ recheck: false })
776
802
  if (this.stopping || !this.config.enabled) return inactive()
777
- out.phase = this.error || this.lastServeError || this.lastServe === undefined || !this.lastStatus || this.lastStatus.BackendState !== 'Running' ? 'error' : 'ready'
778
- if (out.phase === 'error') out.error = this.error || this.lastServeError || 'Routes are not confirmed'
803
+ out.phase = this.error || this.statusError || this.featureError || this.lastServeError || this.lastServe === undefined || !this.lastStatus || this.lastStatus.BackendState !== 'Running' ? 'error' : 'ready'
804
+ if (out.phase === 'error') out.error = this.error || this.statusError || this.featureError || this.lastServeError || 'Routes are not confirmed'
779
805
  out.serveError = this.lastServeError
780
806
  out.serve = describeServeConfig(this.lastServe, dnsName, Object.fromEntries(this.targets.map(target => [target.listen, target.name])))
781
807
  return out
@@ -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.28",
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
+ })
@@ -505,7 +505,6 @@ test('a failed HTTPS query surfaces an error instead of looping in starting', as
505
505
  assert.equal(snap.phase, 'error')
506
506
  assert.match(snap.error, /503 no netmap/)
507
507
  assert.equal(snap.approval, null)
508
- anywhere.error = null
509
508
  feature = () => ({ Complete: false, Text: 'approve', URL: 'https://login.tailscale.com/f/serve?node=1' })
510
509
  anywhere.lastStatusAt = 0
511
510
  snap = await anywhere.snapshot()
@@ -1404,3 +1403,187 @@ test('cancelling a pending HTTPS approval during Stop keeps the result Off', asy
1404
1403
  assert.equal((await anywhere.snapshot()).approval.url, 'https://login.tailscale.com/f/fresh')
1405
1404
  } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1406
1405
  })
1406
+
1407
+ test('a transient status failure recovers on the next snapshot without restarting Anywhere', async t => {
1408
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-status-recovery-'))
1409
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
1410
+ const anywhere = new Anywhere(fakeKernel(tmp))
1411
+ await anywhere.saveConfig({ enabled: true })
1412
+ anywhere.readPid = () => 123
1413
+ let failed = true, statusReads = 0
1414
+ const routes = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 })
1415
+ anywhere.api = {
1416
+ status: async () => {
1417
+ statusReads++
1418
+ if (failed) throw new Error('temporary CLI timeout')
1419
+ return runningStatus()
1420
+ },
1421
+ serveConfig: async () => routes,
1422
+ setServeConfig: async () => assert.fail('confirmed routes do not need rewriting'),
1423
+ up: async () => assert.fail('recovery must not reauthenticate'),
1424
+ }
1425
+ const failure = await anywhere.snapshot()
1426
+ assert.equal(failure.phase, 'error')
1427
+ assert.equal(failure.error, 'temporary CLI timeout')
1428
+ failed = false
1429
+ const recovered = await anywhere.snapshot()
1430
+ assert.equal(recovered.phase, 'ready')
1431
+ assert.equal(recovered.error, null)
1432
+ assert.equal(anywhere.statusError, null)
1433
+ assert.equal(statusReads, 2)
1434
+ })
1435
+
1436
+ test('successful status reads never clear a lifecycle failure', async () => {
1437
+ const anywhere = new Anywhere(fakeKernel(os.tmpdir()))
1438
+ anywhere.config = { enabled: true }
1439
+ anywhere.error = 'could not stop the owned daemon'
1440
+ anywhere.api = { status: async () => runningStatus() }
1441
+ await anywhere.refreshStatus(true)
1442
+ const snapshot = await anywhere.snapshot()
1443
+ assert.equal(snapshot.phase, 'error')
1444
+ assert.equal(snapshot.error, 'could not stop the owned daemon')
1445
+ })
1446
+
1447
+ test('repeated status snapshots join Serve sync without extending its passes', async t => {
1448
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-status-sync-'))
1449
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
1450
+ const anywhere = new Anywhere(fakeKernel(tmp))
1451
+ await anywhere.saveConfig({ enabled: true })
1452
+ anywhere.readPid = () => 123
1453
+ anywhere.api = { status: async () => runningStatus() }
1454
+ anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 })
1455
+ let release, passes = 0
1456
+ anywhere.pushServeConfig = async () => {
1457
+ passes++
1458
+ await new Promise(resolve => { release = resolve })
1459
+ return false
1460
+ }
1461
+ const work = anywhere.sync()
1462
+ // Actual target changes must still get their follow-up pass.
1463
+ const changed = anywhere.sync()
1464
+ const first = Array.from({ length: 6 }, () => anywhere.snapshot())
1465
+ await new Promise(setImmediate)
1466
+ release()
1467
+ await new Promise(setImmediate)
1468
+ assert.equal(passes, 2)
1469
+ const later = Array.from({ length: 6 }, () => anywhere.snapshot())
1470
+ await new Promise(setImmediate)
1471
+ assert.equal(anywhere.syncAgain, false, 'polling does not queue a third pass')
1472
+ release()
1473
+ await Promise.all([work, changed])
1474
+ for (const snapshot of await Promise.all([...first, ...later])) assert.equal(snapshot.phase, 'ready')
1475
+ assert.equal(passes, 2)
1476
+ assert.equal(anywhere.syncing, null)
1477
+ })
1478
+
1479
+ test('unchanged menu reads join a slow Serve sync without requesting another pass', async () => {
1480
+ const anywhere = new Anywhere(fakeKernel(os.tmpdir()))
1481
+ anywhere.config = { enabled: true }
1482
+ anywhere.lastStatus = runningStatus()
1483
+ anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 })
1484
+ let release, passes = 0
1485
+ anywhere.pushServeConfig = async () => {
1486
+ passes++
1487
+ await new Promise(resolve => { release = resolve })
1488
+ return false
1489
+ }
1490
+ const sync = anywhere.sync()
1491
+ const menus = Array.from({ length: 10 }, () => anywhere.rewriteMenu('pinokio.tail1.ts.net', []))
1492
+ assert.equal(anywhere.syncAgain, false)
1493
+ release()
1494
+ await Promise.all([sync, ...menus])
1495
+ assert.equal(passes, 1)
1496
+ })
1497
+
1498
+ test('startup preserves the cause of a failed initial status read and an explicit retry can recover', async t => {
1499
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-start-status-'))
1500
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
1501
+ const anywhere = new Anywhere(fakeKernel(tmp))
1502
+ anywhere.config = { enabled: true }
1503
+ anywhere.readPid = () => 123
1504
+ let failed = true
1505
+ anywhere.api = {
1506
+ cancel() {}, ping: async () => true, setPrefs: async () => {},
1507
+ status: async () => {
1508
+ if (failed) throw new Error('initial status unavailable')
1509
+ return runningStatus()
1510
+ },
1511
+ }
1512
+ await assert.rejects(anywhere.start(), /initial status unavailable/)
1513
+ assert.equal((await anywhere.snapshot()).error, 'initial status unavailable')
1514
+ failed = false
1515
+ assert.equal(await anywhere.start(), true)
1516
+ assert.equal(anywhere.error, null)
1517
+ assert.equal(anywhere.statusError, null)
1518
+ })
1519
+
1520
+ test('concurrent routine status reads share one CLI query and release it after success or failure', async () => {
1521
+ const anywhere = new Anywhere(fakeKernel(os.tmpdir()))
1522
+ let reads = 0, resolve, reject
1523
+ anywhere.api = { status() {
1524
+ reads++
1525
+ return new Promise((ok, fail) => { resolve = ok; reject = fail })
1526
+ } }
1527
+ for (const failure of [false, true]) {
1528
+ anywhere.lastStatusAt = 0
1529
+ const pending = Array.from({ length: 8 }, () => anywhere.refreshStatus(false))
1530
+ assert.equal(reads, failure ? 2 : 1, 'concurrent readers share one CLI process')
1531
+ if (failure) reject(new Error('temporary read failure'))
1532
+ else resolve(runningStatus())
1533
+ const results = await Promise.all(pending)
1534
+ assert.ok(results.every(result => failure ? result === null : result.BackendState === 'Running'))
1535
+ assert.equal(anywhere.statusQuery, null)
1536
+ }
1537
+ const recovered = anywhere.refreshStatus(false)
1538
+ resolve(runningStatus())
1539
+ assert.equal((await recovered).BackendState, 'Running')
1540
+ assert.equal(anywhere.statusError, null)
1541
+ })
1542
+
1543
+ test('a forced status read supersedes older success and failure without letting their finalizers remove its query', async () => {
1544
+ for (const oldFailure of [false, true]) {
1545
+ const anywhere = new Anywhere(fakeKernel(os.tmpdir()))
1546
+ const reads = []
1547
+ anywhere.api = { status() {
1548
+ return new Promise((resolve, reject) => reads.push({ resolve, reject }))
1549
+ } }
1550
+ const older = anywhere.refreshStatus(false)
1551
+ const newer = anywhere.refreshStatus(true)
1552
+ assert.equal(reads.length, 2, 'a forced read bypasses the pending routine query')
1553
+ if (oldFailure) reads[0].reject(new Error('older CLI timeout'))
1554
+ else reads[0].resolve({ BackendState: 'Stopped' })
1555
+ assert.equal(await older, null)
1556
+ const joining = anywhere.refreshStatus(false)
1557
+ assert.equal(reads.length, 2, 'an old finalizer cannot drop the replacement query')
1558
+ reads[1].resolve(runningStatus())
1559
+ await Promise.all([newer, joining])
1560
+ assert.equal(anywhere.lastStatus.BackendState, 'Running')
1561
+ assert.equal(anywhere.statusError, null)
1562
+ assert.equal(anywhere.statusQuery, null)
1563
+ // Also check the reversed completion order: old errors must not erase recovery.
1564
+ const late = anywhere.refreshStatus(true)
1565
+ const latest = anywhere.refreshStatus(true)
1566
+ reads[3].resolve(runningStatus())
1567
+ await latest
1568
+ if (oldFailure) reads[2].reject(new Error('late CLI timeout'))
1569
+ else reads[2].resolve({ BackendState: 'Stopped' })
1570
+ await late
1571
+ assert.equal(anywhere.lastStatus.BackendState, 'Running')
1572
+ assert.equal(anywhere.statusError, null)
1573
+ }
1574
+ })
1575
+
1576
+ test('a replaced CLI client has its own pending status query and ignores the former client', async () => {
1577
+ const anywhere = new Anywhere(fakeKernel(os.tmpdir()))
1578
+ let oldReply, newReply
1579
+ anywhere.api = { status: () => new Promise(resolve => { oldReply = resolve }) }
1580
+ const old = anywhere.refreshStatus(false)
1581
+ anywhere.api = { status: () => new Promise(resolve => { newReply = resolve }) }
1582
+ const fresh = anywhere.refreshStatus(false)
1583
+ newReply(runningStatus())
1584
+ await fresh
1585
+ oldReply({ BackendState: 'Stopped' })
1586
+ assert.equal(await old, null)
1587
+ assert.equal(anywhere.lastStatus.BackendState, 'Running')
1588
+ assert.equal(anywhere.statusQuery, null)
1589
+ })