pinokiod 8.2.28 → 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 +54 -28
- 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/test/anywhere.test.js +184 -1
package/kernel/anywhere/index.js
CHANGED
|
@@ -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
|
}
|
|
@@ -212,10 +215,13 @@ class Anywhere {
|
|
|
212
215
|
if (!this.config.enabled) return false
|
|
213
216
|
if (!(await this.installed())) {
|
|
214
217
|
if (automatic) return false
|
|
215
|
-
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.')
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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.
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
//
|
|
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/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
|
+
})
|
package/test/anywhere.test.js
CHANGED
|
@@ -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
|
+
})
|