pinokiod 8.2.29 → 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.
- package/kernel/anywhere/index.js +1 -1
- package/kernel/anywhere/ports.js +1 -1
- package/kernel/bin/conda.js +1 -1
- package/kernel/bin/setup.js +1 -1
- package/kernel/bin/tailscaled.js +2 -2
- package/package.json +1 -1
- package/server/index.js +1 -1
- package/server/views/anywhere.ejs +90 -47
- package/test/anywhere-clipboard.test.js +18 -17
- package/test/anywhere-onboarding.test.js +95 -0
package/kernel/anywhere/index.js
CHANGED
|
@@ -215,7 +215,7 @@ class Anywhere {
|
|
|
215
215
|
if (!this.config.enabled) return false
|
|
216
216
|
if (!(await this.installed())) {
|
|
217
217
|
if (automatic) return false
|
|
218
|
-
throw new Error('Install the matching Tailscale daemon and CLI through
|
|
218
|
+
throw new Error('Install the matching Tailscale daemon and CLI through Remote setup.')
|
|
219
219
|
}
|
|
220
220
|
if (this.stopping) return false
|
|
221
221
|
this.error = null
|
package/kernel/anywhere/ports.js
CHANGED
|
@@ -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('
|
|
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
|
package/kernel/bin/conda.js
CHANGED
|
@@ -274,7 +274,7 @@ report_errors: false`)
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
if (this.kernel.anywhere) {
|
|
277
|
-
ondata({ raw: `stopping
|
|
277
|
+
ondata({ raw: `stopping Remote before replacing Miniforge...\r\n` })
|
|
278
278
|
await this.kernel.anywhere.stop()
|
|
279
279
|
}
|
|
280
280
|
|
package/kernel/bin/setup.js
CHANGED
|
@@ -275,7 +275,7 @@ module.exports = {
|
|
|
275
275
|
]
|
|
276
276
|
return {
|
|
277
277
|
icon: "fa-solid fa-mobile-screen",
|
|
278
|
-
title: "
|
|
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: [
|
package/kernel/bin/tailscaled.js
CHANGED
|
@@ -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
|
|
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
|
|
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
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: "
|
|
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-
|
|
44
|
-
|
|
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-
|
|
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
|
|
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
|
|
190
|
-
<p
|
|
191
|
-
<p
|
|
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
|
|
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
|
|
209
|
-
<div class='anywhere-qr'>
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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 = '
|
|
335
|
+
copyStatus.textContent = 'Link copied.'
|
|
309
336
|
}
|
|
310
337
|
}
|
|
311
338
|
copying = false
|
|
312
|
-
copyButton.disabled = current.phase !== 'ready' || !current.
|
|
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
|
|
332
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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.
|
|
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, [
|
|
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, [
|
|
64
|
-
assert.equal(h.feedback.textContent, '
|
|
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'),
|
|
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, [[
|
|
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, [
|
|
103
|
-
assert.equal(h.feedback.textContent, '
|
|
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,
|
|
123
|
+
assert.equal(h.input.value, link)
|
|
123
124
|
assert.equal(h.input.selectionStart, 0)
|
|
124
|
-
assert.equal(h.input.selectionEnd,
|
|
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, /
|
|
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,
|
|
132
|
-
h.state.status.
|
|
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.
|
|
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
|
|
169
|
-
for (const change of [{
|
|
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
|
+
})
|