pinokiod 8.2.25 → 8.2.27

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.25",
3
+ "version": "8.2.27",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -49,6 +49,14 @@ body.dark .anywhere-card { border-color: rgba(255,255,255,0.1); background: rgba
49
49
  }
50
50
  body.dark .anywhere-address { background: rgba(255,255,255,0.08); }
51
51
  .anywhere-address a { color: inherit; text-decoration: none; }
52
+ .anywhere-copy { min-height: 44px; }
53
+ .anywhere-copy:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
54
+ .anywhere-copy-manual { width: 100%; min-width: 0; }
55
+ .anywhere-copy-manual input {
56
+ box-sizing: border-box; width: 100%; min-height: 44px; padding: 8px;
57
+ font-size: 16px; color: inherit; background: transparent; border: 1px solid currentColor;
58
+ border-radius: 8px; user-select: text; -webkit-user-select: text;
59
+ }
52
60
  .anywhere-btn {
53
61
  display: inline-flex; align-items: center; gap: 8px;
54
62
  padding: 10px 16px; border-radius: 8px; border: none; cursor: pointer;
@@ -201,6 +209,11 @@ document.addEventListener('DOMContentLoaded', function() {
201
209
  <div class='anywhere-qr'>
202
210
  <img id='anywhere-qr' alt='QR code to connect' src=''>
203
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'>
216
+ </div>
204
217
  <div class='anywhere-muted'>signed in as <b id='anywhere-account'></b></div>
205
218
  <div class='anywhere-waiting' id='anywhere-waiting'><i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for another device to join…</div>
206
219
  </div>
@@ -254,8 +267,54 @@ document.addEventListener('DOMContentLoaded', function() {
254
267
  const switchEl = document.getElementById('anywhere-switch')
255
268
  const switchLabel = document.getElementById('anywhere-switch-label')
256
269
  const agent = document.body.getAttribute('data-agent')
270
+ const copyButton = document.getElementById('anywhere-copy')
271
+ const copyStatus = document.getElementById('anywhere-copy-status')
272
+ const copyManual = document.getElementById('anywhere-copy-manual')
273
+ const copyValue = document.getElementById('anywhere-copy-value')
257
274
  let current = <%- JSON.stringify(status || {}).replace(/</g, '\\u003c') %>
258
275
  let busy = false
276
+ let copying = false
277
+
278
+ copyButton.addEventListener('click', async () => {
279
+ const url = current.address
280
+ if (copying || current.phase !== 'ready' || !url) return
281
+ const previousFocus = document.activeElement
282
+ copying = true
283
+ copyButton.disabled = true
284
+ copyStatus.textContent = 'Copying…'
285
+ const stillCurrent = () => current.phase === 'ready' && current.address === url
286
+ let copied = false
287
+ try {
288
+ // Call directly from the click: Safari requires a user gesture.
289
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
290
+ await navigator.clipboard.writeText(url)
291
+ copied = true
292
+ }
293
+ } catch (_) { /* Try selection-based copying when clipboard access is denied. */ }
294
+ if (stillCurrent()) {
295
+ if (!copied) {
296
+ copyValue.value = url
297
+ copyManual.hidden = false
298
+ copyStatus.textContent = 'Automatic copy is unavailable. Touch and hold the URL to copy it, or press Ctrl+C / ⌘C.'
299
+ try {
300
+ copyValue.focus({ preventScroll: true })
301
+ copyValue.select()
302
+ copyValue.setSelectionRange(0, url.length)
303
+ copied = document.execCommand('copy') === true
304
+ } catch (_) { /* Leave the visible URL available for manual copying. */ }
305
+ }
306
+ if (copied) {
307
+ copyManual.hidden = true
308
+ copyStatus.textContent = 'URL copied.'
309
+ }
310
+ }
311
+ copying = false
312
+ copyButton.disabled = current.phase !== 'ready' || !current.address
313
+ if (copied && stillCurrent() && previousFocus &&
314
+ (document.activeElement === copyValue || document.activeElement === document.body)) {
315
+ previousFocus.focus({ preventScroll: true })
316
+ }
317
+ })
259
318
 
260
319
  const esc = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
261
320
  const openExternal = (href) => {
@@ -292,7 +351,12 @@ document.addEventListener('DOMContentLoaded', function() {
292
351
  }
293
352
 
294
353
  const render = (status) => {
354
+ if (current.address !== (status || {}).address || current.phase !== (status || {}).phase) {
355
+ copyManual.hidden = true
356
+ copyStatus.textContent = ''
357
+ }
295
358
  current = status || {}
359
+ copyButton.disabled = copying || current.phase !== 'ready' || !current.address
296
360
  const phase = current.phase && current.phase !== 'setup' ? current.phase : 'starting'
297
361
  const on = !!current.enabled && phase !== 'off' && phase !== 'setup'
298
362
  switchEl.dataset.on = on ? 'true' : 'false'
@@ -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
 
@@ -0,0 +1,184 @@
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
+ const filename = path.resolve(__dirname, '../server/views/anywhere.ejs')
9
+ const template = fs.readFileSync(filename, 'utf8')
10
+ const address = 'https://pinokio-1.tail123.ts.net'
11
+ const tick = () => new Promise((resolve) => setImmediate(resolve))
12
+
13
+ function createHarness(t, options = {}) {
14
+ const state = { status: { phase: 'ready', enabled: true, address }, timers: [], opened: [] }
15
+ Object.assign(state.status, options.status)
16
+ const html = ejs.render(template, {
17
+ theme: 'light', agent: options.agent || 'browser', status: state.status
18
+ }, { filename, includer: () => ({ template: ' ' }) })
19
+ const virtualConsole = new VirtualConsole()
20
+ virtualConsole.on('jsdomError', (error) => { throw error })
21
+ const dom = new JSDOM(html, {
22
+ url: options.origin || 'http://127.0.0.1:42000/',
23
+ runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole,
24
+ beforeParse(window) {
25
+ if (options.clipboard) {
26
+ Object.defineProperty(window.navigator, 'clipboard', { value: options.clipboard })
27
+ }
28
+ window.document.execCommand = options.execCommand
29
+ window.setTimeout = (callback, delay) => state.timers.push({ callback, delay })
30
+ window.fetch = async (url) => {
31
+ assert.equal(url, '/anywhere/status', 'copying must not use the server clipboard')
32
+ return { ok: true, json: async () => ({ ...state.status }) }
33
+ }
34
+ window.open = (...args) => state.opened.push(args)
35
+ }
36
+ })
37
+ t.after(() => dom.window.close())
38
+ const document = dom.window.document
39
+ return {
40
+ state, document,
41
+ button: document.getElementById('anywhere-copy'),
42
+ feedback: document.getElementById('anywhere-copy-status'),
43
+ manual: document.getElementById('anywhere-copy-manual'),
44
+ input: document.getElementById('anywhere-copy-value'),
45
+ poll: () => state.timers.shift().callback()
46
+ }
47
+ }
48
+
49
+ for (const agent of ['browser', 'electron']) {
50
+ test(`Remote copies the full URL directly from the ${agent} click without opening a window`, async (t) => {
51
+ const copied = []
52
+ const h = createHarness(t, {
53
+ agent,
54
+ clipboard: { writeText: async (text) => { copied.push(text) } },
55
+ execCommand: () => assert.fail('modern clipboard succeeded')
56
+ })
57
+ h.button.focus()
58
+ h.button.click()
59
+ assert.deepEqual(copied, [address], 'clipboard is invoked synchronously in the click')
60
+ assert.equal(h.button.disabled, true)
61
+ h.button.click()
62
+ await tick()
63
+ assert.deepEqual(copied, [address], 'pending copies are not duplicated')
64
+ assert.equal(h.feedback.textContent, 'URL copied.')
65
+ assert.equal(h.manual.hidden, true)
66
+ assert.equal(h.button.disabled, false)
67
+ assert.equal(h.document.activeElement, h.button)
68
+ assert.deepEqual(h.state.opened, [])
69
+ assert.equal(h.document.getElementById('anywhere-address').getAttribute('href'), address)
70
+ assert.equal(h.feedback.getAttribute('role'), 'status')
71
+ if (agent === 'electron') {
72
+ h.document.getElementById('anywhere-address').click()
73
+ assert.deepEqual(h.state.opened, [[address + '/', '_blank', 'browser']])
74
+ }
75
+ })
76
+ }
77
+
78
+ for (const scenario of ['insecure LAN', 'denied', 'synchronous failure', 'electron']) {
79
+ test(`Remote falls back to selected text copying: ${scenario}`, async (t) => {
80
+ const copied = []
81
+ let h
82
+ h = createHarness(t, {
83
+ agent: scenario === 'electron' ? 'electron' : 'browser',
84
+ origin: scenario === 'insecure LAN' ? 'http://192.168.1.10:42000/' : 'https://pinokio.tail123.ts.net/',
85
+ clipboard: scenario === 'denied'
86
+ ? { writeText: async () => { throw new Error('NotAllowedError') } }
87
+ : scenario === 'synchronous failure'
88
+ ? { writeText: () => { throw new Error('clipboard unavailable') } }
89
+ : undefined,
90
+ execCommand(command) {
91
+ assert.equal(command, 'copy')
92
+ assert.equal(h.document.activeElement, h.input)
93
+ assert.equal(h.input.readOnly, true)
94
+ assert.equal(h.manual.hidden, false)
95
+ copied.push(h.input.value.slice(h.input.selectionStart, h.input.selectionEnd))
96
+ return true
97
+ }
98
+ })
99
+ h.button.focus()
100
+ h.button.click()
101
+ await tick()
102
+ assert.deepEqual(copied, [address])
103
+ assert.equal(h.feedback.textContent, 'URL copied.')
104
+ assert.equal(h.manual.hidden, true)
105
+ assert.equal(h.document.activeElement, h.button)
106
+ assert.deepEqual(h.state.opened, [])
107
+ })
108
+ }
109
+
110
+ for (const failure of ['false', 'undefined', 'throws', 'unavailable']) {
111
+ test(`Remote offers selected manual copy without false success when execCommand ${failure}`, async (t) => {
112
+ const h = createHarness(t, {
113
+ clipboard: { writeText: async () => { throw new Error('denied') } },
114
+ execCommand: failure === 'unavailable' ? undefined : () => {
115
+ if (failure === 'throws') throw new Error('denied')
116
+ return failure === 'false' ? false : undefined
117
+ }
118
+ })
119
+ h.button.click()
120
+ await tick()
121
+ assert.equal(h.manual.hidden, false)
122
+ assert.equal(h.input.value, address)
123
+ assert.equal(h.input.selectionStart, 0)
124
+ assert.equal(h.input.selectionEnd, address.length)
125
+ assert.equal(h.document.activeElement, h.input)
126
+ assert.match(h.feedback.textContent, /Touch and hold.*Ctrl\+C \/ ⌘C/)
127
+ assert.doesNotMatch(h.feedback.textContent, /URL copied/)
128
+ assert.equal(h.button.disabled, false)
129
+ await h.poll()
130
+ 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'
133
+ await h.poll()
134
+ assert.equal(h.manual.hidden, true, 'a changed URL clears the stale manual field')
135
+ assert.equal(h.feedback.textContent, '')
136
+ })
137
+ }
138
+
139
+ test('Remote does not copy a placeholder or an unavailable address', async (t) => {
140
+ const h = createHarness(t, {
141
+ status: { address: '' },
142
+ clipboard: { writeText: () => assert.fail('no URL to copy') },
143
+ execCommand: () => assert.fail('no URL to copy')
144
+ })
145
+ assert.equal(h.button.disabled, true)
146
+ h.button.click()
147
+ await tick()
148
+ assert.equal(h.feedback.textContent, '')
149
+ h.state.status.address = address
150
+ await h.poll()
151
+ assert.equal(h.button.disabled, false)
152
+ h.state.status.phase = 'off'
153
+ await h.poll()
154
+ assert.equal(h.button.disabled, true)
155
+ })
156
+
157
+ test('Remote restores keyboard focus after disabling the button during modern copy', async (t) => {
158
+ let h
159
+ h = createHarness(t, {
160
+ clipboard: { writeText: async () => { h.button.blur() } }
161
+ })
162
+ h.button.focus()
163
+ h.button.click()
164
+ await tick()
165
+ assert.equal(h.document.activeElement, h.button)
166
+ })
167
+
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' }]) {
170
+ let rejectCopy
171
+ const h = createHarness(t, {
172
+ clipboard: { writeText: () => new Promise((resolve, reject) => { rejectCopy = reject }) },
173
+ execCommand: () => assert.fail('must not select a stale URL')
174
+ })
175
+ h.button.click()
176
+ Object.assign(h.state.status, change)
177
+ await h.poll()
178
+ rejectCopy(new Error('denied'))
179
+ await tick()
180
+ assert.equal(h.manual.hidden, true)
181
+ assert.equal(h.feedback.textContent, '')
182
+ assert.equal(h.button.disabled, change.phase === 'off')
183
+ }
184
+ })
@@ -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.',