pinokiod 8.2.25 → 8.2.26
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.
- package/package.json +1 -1
- package/server/views/anywhere.ejs +64 -0
- package/test/anywhere-clipboard.test.js +184 -0
package/package.json
CHANGED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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'
|
|
@@ -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
|
+
})
|