pinokiod 8.2.26 → 8.2.28

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.
@@ -671,9 +671,21 @@ class Anywhere {
671
671
  await this.loadConfig()
672
672
  const installed = await this.installed()
673
673
  const enabled = !!this.config.enabled
674
+ const info = this.kernel.sysinfo || {}
674
675
  const base = {
675
676
  installed,
676
677
  enabled,
678
+ // Display metadata only; the existing Tailscale identity/address stays intact.
679
+ machineName: os.hostname(),
680
+ system: {
681
+ platform: this.platform,
682
+ arch: this.kernel.arch || os.arch(),
683
+ os: info.osInfo?.distro,
684
+ cpu: info.cpu?.brand,
685
+ gpu: info.gpu_model,
686
+ ram: info.ram,
687
+ vram: info.vram,
688
+ },
677
689
  error: this.error,
678
690
  serveError: this.lastServeError,
679
691
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.2.26",
3
+ "version": "8.2.28",
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>
@@ -13,7 +13,7 @@ const readyAnywhere = () => {
13
13
  anywhere.config = { enabled: true }
14
14
  anywhere.lastStatus = { Self: { DNSName: 'pinokio.tail1.ts.net.' } }
15
15
  anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', targets: [{ listen: 43000, target: 7860 }] })
16
- anywhere.snapshot = async () => ({ phase: 'ready', dnsName: 'pinokio.tail1.ts.net', serve: describeServeConfig(anywhere.lastServe) })
16
+ anywhere.snapshot = async () => ({ phase: 'ready', machineName: 'OS-HOST', system: { platform: 'linux', arch: 'x64', cpu: 'Test CPU', gpu: 'Test GPU', ram: 64, vram: 24 }, dnsName: 'pinokio.tail1.ts.net', serve: describeServeConfig(anywhere.lastServe) })
17
17
  return anywhere
18
18
  }
19
19
 
@@ -36,10 +36,14 @@ test('status carries app ids and complete remote addresses, and no installed cou
36
36
  ]) {
37
37
  readyUrl = url
38
38
  const status = await Server.prototype.composeAnywhereStatus.call(fake, {})
39
+ assert.equal(status.machineName, 'OS-HOST')
40
+ assert.equal(status.system.gpu, 'Test GPU')
39
41
  assert.equal(status.installed, undefined)
40
42
  assert.deepEqual(status.apps.map((a) => [a.id, a.state, a.remote]), [['comfy', 'ready', expected], ['forge', 'starting', null], ['unrouted', 'publishing', null]])
41
43
  }
42
44
  const light = await Server.prototype.composeAnywhereStatus.call(fake, { light: true })
45
+ assert.equal(light.machineName, 'OS-HOST')
46
+ assert.equal(light.system.ram, 64)
43
47
  assert.equal(light.apps, undefined, 'the light status is unchanged')
44
48
  })
45
49
 
@@ -14,6 +14,37 @@ const { spawn } = require('node:child_process')
14
14
  const { once, EventEmitter } = require('node:events')
15
15
  const Setup = require('../kernel/bin/setup')
16
16
 
17
+ test('status reports os.hostname and cached system specs on every platform without spawning commands', async () => {
18
+ const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
19
+ const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
20
+ for (const platform of ['darwin', 'win32', 'linux']) {
21
+ const context = { module: { exports: {} }, process, console, require(name) {
22
+ if (name === 'os') return { hostname: () => 'OS-HOST', arch: () => 'x64' }
23
+ if (name === 'child_process') return { execFile() { assert.fail('metadata must not spawn commands') } }
24
+ return baseRequire(name)
25
+ } }
26
+ vm.runInNewContext(source, context)
27
+ const kernel = { platform, sysinfo: {
28
+ osInfo: { distro: 'Test OS' }, cpu: { brand: 'Test CPU' },
29
+ gpu_model: 'Test GPU', ram: 64, vram: 24,
30
+ } }
31
+ const anywhere = new context.module.exports(kernel)
32
+ anywhere.loadConfig = async () => { anywhere.config = { enabled: false, hostname: 'pinokio' } }
33
+ for (const installed of [false, true]) {
34
+ anywhere.installed = async () => installed
35
+ const status = await anywhere.snapshot()
36
+ assert.equal(status.machineName, 'OS-HOST')
37
+ assert.deepEqual(JSON.parse(JSON.stringify(status.system)), {
38
+ platform, arch: 'x64', os: 'Test OS', cpu: 'Test CPU', gpu: 'Test GPU', ram: 64, vram: 24,
39
+ })
40
+ assert.equal(status.phase, installed ? 'off' : 'setup')
41
+ assert.equal(anywhere.config.hostname, 'pinokio')
42
+ }
43
+ delete kernel.sysinfo
44
+ assert.deepEqual(JSON.parse(JSON.stringify((await anywhere.snapshot()).system)), { platform, arch: 'x64' }, 'startup before system info is ready')
45
+ }
46
+ })
47
+
17
48
  test('serve config maps pinokiod to 443 and each app to its stable remote port', () => {
18
49
  const config = buildServeConfig({
19
50
  dnsName: 'pinokio.tail1234.ts.net.',
@@ -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
+ })