pinokiod 8.2.27 → 8.2.29

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
  }
@@ -216,6 +219,9 @@ class Anywhere {
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.2.27",
3
+ "version": "8.2.29",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,84 @@
1
+ (() => {
2
+ const trigger = document.querySelector('[data-remote-nav]')
3
+ if (!trigger || trigger.dataset.initialized) return
4
+ trigger.dataset.initialized = 'true'
5
+
6
+ let payload = null
7
+ let revision = 0
8
+ let inFlight = false
9
+ const labels = {
10
+ ready: 'Connected',
11
+ off: 'Off',
12
+ setup: 'Setup required',
13
+ starting: 'Connecting',
14
+ login: 'Sign-in required',
15
+ machine_auth: 'Device approval required',
16
+ https: 'HTTPS approval required',
17
+ error: 'Connection error'
18
+ }
19
+ const render = (data) => {
20
+ const phase = data && data.phase
21
+ const label = labels[phase] || 'Connection status unavailable'
22
+ const pending = ['starting', 'login', 'machine_auth', 'https', 'setup'].includes(phase)
23
+ trigger.dataset.remoteStatus = phase === 'ready' ? 'ready' : phase === 'error' ? 'error' : pending ? 'pending' : phase === 'off' ? 'off' : 'unknown'
24
+ const description = `Remote — ${label}`
25
+ trigger.setAttribute('title', description)
26
+ trigger.setAttribute('aria-label', description)
27
+ trigger.setAttribute('data-tippy-content', description)
28
+ if (trigger._tippy) trigger._tippy.setContent(description)
29
+
30
+ const tab = document.querySelector('[data-main-sidebar-anywhere-tab]')
31
+ const badge = document.querySelector('[data-main-sidebar-anywhere-status]')
32
+ if (!tab || !badge) return
33
+ tab.setAttribute('title', description)
34
+ tab.setAttribute('aria-label', description)
35
+ badge.hidden = !labels[phase] || phase === 'setup'
36
+ badge.textContent = phase === 'ready' ? 'ON' : phase === 'off' || phase === 'error' ? 'OFF' : '…'
37
+ badge.dataset.state = phase === 'ready' ? 'on' : 'off'
38
+ }
39
+ document.addEventListener('pinokio:anywhere-status', (event) => {
40
+ revision += 1
41
+ const detail = event.detail || {}
42
+ payload = detail.payload || detail
43
+ render(payload)
44
+ })
45
+
46
+ const refresh = async () => {
47
+ if (inFlight || document.hidden) return
48
+ inFlight = true
49
+ const requestRevision = revision
50
+ const controller = new AbortController()
51
+ const timeout = setTimeout(() => controller.abort(), 10000)
52
+ try {
53
+ const response = await fetch('/anywhere/status?light=1', { cache: 'no-store', signal: controller.signal })
54
+ if (!response.ok) throw new Error(`Remote status failed: ${response.status}`)
55
+ const data = await response.json()
56
+ if (revision === requestRevision) {
57
+ payload = data
58
+ render(payload)
59
+ }
60
+ } catch (_) {
61
+ if (revision === requestRevision) {
62
+ payload = {}
63
+ render(payload)
64
+ }
65
+ } finally {
66
+ clearTimeout(timeout)
67
+ inFlight = false
68
+ }
69
+ }
70
+ const init = () => {
71
+ if (payload) render(payload)
72
+ // The Remote page already polls and broadcasts its full status.
73
+ if (document.getElementById('anywhere-root')) return
74
+ refresh()
75
+ setInterval(refresh, 5000)
76
+ document.addEventListener('visibilitychange', refresh)
77
+ window.addEventListener('focus', refresh)
78
+ }
79
+ if (document.readyState === 'loading') {
80
+ document.addEventListener('DOMContentLoaded', init, { once: true })
81
+ } else {
82
+ init()
83
+ }
84
+ })()
@@ -21,3 +21,4 @@
21
21
  </div>
22
22
  </div>
23
23
  </div>
24
+ <%- include('remote_nav_button') %>
@@ -1,4 +1,54 @@
1
1
  <style>
2
+ .remote-nav-button {
3
+ position: relative;
4
+ width: 40px;
5
+ height: 30px;
6
+ min-width: 40px;
7
+ min-height: 30px;
8
+ padding: 0;
9
+ border-radius: 9px;
10
+ box-sizing: border-box;
11
+ }
12
+ .remote-nav-button__icon {
13
+ position: relative;
14
+ display: inline-flex;
15
+ width: 18px;
16
+ height: 18px;
17
+ }
18
+ .remote-nav-button__icon::before {
19
+ content: "";
20
+ position: absolute;
21
+ inset: 0;
22
+ background: currentColor;
23
+ -webkit-mask: url('/remote-control.svg') center / contain no-repeat;
24
+ mask: url('/remote-control.svg') center / contain no-repeat;
25
+ }
26
+ .remote-nav-button__dot {
27
+ position: absolute;
28
+ top: -4px;
29
+ right: -5px;
30
+ width: 7px;
31
+ height: 7px;
32
+ border: 1.5px solid var(--home-page-nav-bg, #fff);
33
+ border-radius: 999px;
34
+ background: #9aa1ad;
35
+ }
36
+ .remote-nav-button[data-remote-status="ready"] .remote-nav-button__dot {
37
+ background: #2db45c;
38
+ }
39
+ .remote-nav-button[data-remote-status="pending"] .remote-nav-button__dot {
40
+ background: #d98b1f;
41
+ }
42
+ .remote-nav-button[data-remote-status="error"] .remote-nav-button__dot {
43
+ background: #cf4545;
44
+ }
45
+ .remote-nav-button:focus-visible {
46
+ outline: 2px solid currentColor;
47
+ outline-offset: 2px;
48
+ }
49
+ body.dark .remote-nav-button__dot {
50
+ border-color: var(--home-page-nav-bg, #1b1c1d);
51
+ }
2
52
  .home-server-popover {
3
53
  position: relative;
4
54
  display: inline-flex;
@@ -15,6 +65,8 @@
15
65
  border-radius: 9px;
16
66
  box-sizing: border-box;
17
67
  }
68
+ .remote-nav-button:hover,
69
+ .remote-nav-button:focus-visible,
18
70
  .home-server-popover__trigger:hover,
19
71
  .home-server-popover__trigger:focus-visible,
20
72
  .home-server-popover[data-home-server-open="true"] .home-server-popover__trigger {
@@ -512,6 +564,8 @@ body.dark .home-server-popover__panel {
512
564
  box-shadow: 0 18px 42px rgba(0, 0, 0, 0.3);
513
565
  color: rgba(239, 242, 247, 0.92);
514
566
  }
567
+ body.dark .remote-nav-button:hover,
568
+ body.dark .remote-nav-button:focus-visible,
515
569
  body.dark .home-server-popover__trigger:hover,
516
570
  body.dark .home-server-popover__trigger:focus-visible,
517
571
  body.dark .home-server-popover[data-home-server-open="true"] .home-server-popover__trigger {
@@ -1265,3 +1319,4 @@ body.dark .home-server-popover__qr-close:focus-visible {
1265
1319
  })
1266
1320
  })()
1267
1321
  </script>
1322
+ <script src="/remote-nav.js"></script>
@@ -122,43 +122,6 @@
122
122
 
123
123
  initHomeServerStatusBadge();
124
124
 
125
- const initAnywhereStatusBadge = () => {
126
- if (!sidebar || window.__pinokioMainSidebarAnywhereStatusInit) return;
127
- const tab = sidebar.querySelector("[data-main-sidebar-anywhere-tab]");
128
- const badge = sidebar.querySelector("[data-main-sidebar-anywhere-status]");
129
- if (!tab || !badge) return;
130
- window.__pinokioMainSidebarAnywhereStatusInit = true;
131
- const render = (data) => {
132
- const phase = data && data.phase ? String(data.phase) : "off";
133
- if (phase === "setup") {
134
- badge.hidden = true;
135
- delete badge.dataset.state;
136
- return;
137
- }
138
- const on = phase === "ready";
139
- const label = on ? "ON" : (phase === "off" || phase === "error" ? "OFF" : "…");
140
- badge.textContent = label;
141
- badge.hidden = false;
142
- badge.dataset.state = on ? "on" : "off";
143
- tab.setAttribute("title", `Remote ${label}`);
144
- };
145
- document.addEventListener("pinokio:anywhere-status", (event) => {
146
- const detail = event && event.detail ? event.detail : {};
147
- render(detail.payload || detail);
148
- });
149
- fetch("/anywhere/status?light=1", { cache: "no-store" })
150
- .then((response) => {
151
- if (!response.ok) throw new Error(`Anywhere status failed: ${response.status}`);
152
- return response.json();
153
- })
154
- .then(render)
155
- .catch(() => {
156
- badge.hidden = true;
157
- delete badge.dataset.state;
158
- });
159
- };
160
- initAnywhereStatusBadge();
161
-
162
125
  if (body && sidebar && headerToggle && !headerToggle.dataset.mainSidebarHeaderToggle) {
163
126
  headerToggle.dataset.mainSidebarHeaderToggle = "true";
164
127
  headerToggle.classList.add("sidebar-toggle", "main-sidebar-header-toggle");
@@ -0,0 +1,5 @@
1
+ <a class="btn2 remote-nav-button" href="/anywhere" data-remote-nav data-remote-status="loading" data-tippy-content="Remote — Checking connection" title="Remote — Checking connection" aria-label="Remote — Checking connection">
2
+ <span class="remote-nav-button__icon" aria-hidden="true">
3
+ <span class="remote-nav-button__dot"></span>
4
+ </span>
5
+ </a>
@@ -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
+ })
@@ -0,0 +1,124 @@
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 } = require('jsdom')
7
+
8
+ const root = path.resolve(__dirname, '..')
9
+ const script = fs.readFileSync(path.join(root, 'server/public/remote-nav.js'), 'utf8')
10
+ const render = (file, data = {}) => ejs.render(fs.readFileSync(file, 'utf8'), data, { filename: file })
11
+ const tick = () => new Promise(resolve => setImmediate(resolve))
12
+
13
+ async function harness(t, { remotePage = false, fetch } = {}) {
14
+ const header = render(path.join(root, 'server/views/partials/app_navheader.ejs'), { agent: 'electron' })
15
+ const sidebar = render(path.join(root, 'server/views/partials/main_sidebar.ejs'), { selected: 'home' })
16
+ const dom = new JSDOM(`<!doctype html><body>${header}${sidebar}${remotePage ? '<div id="anywhere-root"></div>' : ''}</body>`, {
17
+ url: 'http://localhost:42000/home', runScripts: 'outside-only', pretendToBeVisual: true
18
+ })
19
+ t.after(() => dom.window.close())
20
+ const state = { payload: { phase: 'ready', enabled: true }, requests: [], intervals: [] }
21
+ dom.window.fetch = async (...args) => {
22
+ state.requests.push(args)
23
+ return fetch ? fetch(...args) : { ok: true, json: async () => state.payload }
24
+ }
25
+ dom.window.setInterval = (callback, delay) => {
26
+ state.intervals.push({ callback, delay })
27
+ return state.intervals.length
28
+ }
29
+ await tick()
30
+ dom.window.eval(script)
31
+ await tick()
32
+ const document = dom.window.document
33
+ const trigger = document.querySelector('[data-remote-nav]')
34
+ const badge = document.querySelector('[data-main-sidebar-anywhere-status]')
35
+ const dispatch = (payload) => document.dispatchEvent(new dom.window.CustomEvent('pinokio:anywhere-status', { detail: { payload } }))
36
+ return { dom, document, state, trigger, badge, dispatch }
37
+ }
38
+
39
+ test('Remote links to the current-view page beside Home Server and shares live status with the sidebar', async t => {
40
+ const { document, state, trigger, badge, dispatch } = await harness(t)
41
+ assert.equal(document.querySelector('[data-home-server-popover]').nextElementSibling, trigger)
42
+ assert.equal(trigger.getAttribute('href'), '/anywhere')
43
+ assert.equal(trigger.getAttribute('target'), null)
44
+ assert.equal(trigger.dataset.remoteStatus, 'ready')
45
+ assert.equal(badge.textContent, 'ON')
46
+ assert.equal(state.requests.length, 1)
47
+ assert.equal(state.requests[0][0], '/anywhere/status?light=1')
48
+ assert.equal(state.requests[0][1].cache, 'no-store')
49
+
50
+ let tooltip
51
+ trigger._tippy = { setContent: value => { tooltip = value } }
52
+ const phases = [
53
+ ['off', 'off', 'Off', 'OFF'],
54
+ ['starting', 'pending', 'Connecting', '…'],
55
+ ['login', 'pending', 'Sign-in required', '…'],
56
+ ['machine_auth', 'pending', 'Device approval required', '…'],
57
+ ['https', 'pending', 'HTTPS approval required', '…'],
58
+ ['setup', 'pending', 'Setup required', '…'],
59
+ ['error', 'error', 'Connection error', 'OFF'],
60
+ ['ready', 'ready', 'Connected', 'ON']
61
+ ]
62
+ for (const [phase, status, label, badgeLabel] of phases) {
63
+ dispatch({ phase, enabled: true })
64
+ assert.equal(trigger.dataset.remoteStatus, status)
65
+ assert.equal(trigger.getAttribute('aria-label'), `Remote — ${label}`)
66
+ assert.equal(tooltip, `Remote — ${label}`)
67
+ assert.equal(badge.textContent, badgeLabel)
68
+ assert.equal(badge.hidden, phase === 'setup')
69
+ assert.equal(document.querySelector('[data-main-sidebar-anywhere-tab]').getAttribute('aria-label'), `Remote — ${label}`)
70
+ }
71
+ })
72
+
73
+ test('Remote status refreshes off-page, pauses when hidden, and recovers from a failed request', async t => {
74
+ let fail = false
75
+ const { dom, document, state, trigger, badge } = await harness(t, {
76
+ fetch: async () => {
77
+ if (fail) throw new Error('network unavailable')
78
+ return { ok: true, json: async () => ({ phase: 'off' }) }
79
+ }
80
+ })
81
+ assert.equal(state.intervals.length, 1)
82
+ assert.equal(state.intervals[0].delay, 5000)
83
+ fail = true
84
+ await state.intervals[0].callback()
85
+ assert.equal(trigger.dataset.remoteStatus, 'unknown')
86
+ assert.match(trigger.getAttribute('aria-label'), /status unavailable/)
87
+ assert.equal(badge.hidden, true)
88
+ Object.defineProperty(document, 'hidden', { configurable: true, value: true })
89
+ await state.intervals[0].callback()
90
+ assert.equal(state.requests.length, 2)
91
+ fail = false
92
+ Object.defineProperty(document, 'hidden', { configurable: true, value: false })
93
+ document.dispatchEvent(new dom.window.Event('visibilitychange'))
94
+ await tick()
95
+ assert.equal(trigger.dataset.remoteStatus, 'off')
96
+ assert.equal(badge.hidden, false)
97
+ assert.equal(badge.textContent, 'OFF')
98
+ })
99
+
100
+ test('an older status request cannot overwrite a Remote page action; duplicate initialization does not add polling', async t => {
101
+ let resolveResponse
102
+ const response = new Promise(resolve => { resolveResponse = resolve })
103
+ const { dom, state, trigger, dispatch } = await harness(t, { fetch: () => response })
104
+ await state.intervals[0].callback()
105
+ assert.equal(state.requests.length, 1)
106
+ dispatch({ phase: 'off' })
107
+ resolveResponse({ ok: true, json: async () => ({ phase: 'ready' }) })
108
+ await tick()
109
+ assert.equal(trigger.dataset.remoteStatus, 'off')
110
+ dom.window.eval(script)
111
+ assert.equal(state.intervals.length, 1)
112
+ })
113
+
114
+ test('the Remote page uses its existing status broadcasts without another poller', async t => {
115
+ const { state, trigger, badge, dispatch } = await harness(t, { remotePage: true })
116
+ assert.equal(state.requests.length, 0)
117
+ assert.equal(state.intervals.length, 0)
118
+ dispatch({ phase: 'ready' })
119
+ assert.equal(trigger.dataset.remoteStatus, 'ready')
120
+ assert.equal(badge.textContent, 'ON')
121
+ dispatch({ phase: 'login', enabled: true })
122
+ assert.equal(trigger.dataset.remoteStatus, 'pending')
123
+ assert.equal(badge.textContent, '…')
124
+ })