pinokiod 8.2.27 → 8.2.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/public/remote-nav.js +84 -0
- package/server/views/partials/home_server_popover.ejs +1 -0
- package/server/views/partials/home_server_popover_assets.ejs +55 -0
- package/server/views/partials/main_sidebar.ejs +0 -37
- package/server/views/partials/remote_nav_button.ejs +5 -0
- package/test/remote-nav-ui.test.js +124 -0
package/package.json
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const trigger = document.querySelector('[data-remote-nav]')
|
|
3
|
+
if (!trigger || trigger.dataset.initialized) return
|
|
4
|
+
trigger.dataset.initialized = 'true'
|
|
5
|
+
|
|
6
|
+
let payload = null
|
|
7
|
+
let revision = 0
|
|
8
|
+
let inFlight = false
|
|
9
|
+
const labels = {
|
|
10
|
+
ready: 'Connected',
|
|
11
|
+
off: 'Off',
|
|
12
|
+
setup: 'Setup required',
|
|
13
|
+
starting: 'Connecting',
|
|
14
|
+
login: 'Sign-in required',
|
|
15
|
+
machine_auth: 'Device approval required',
|
|
16
|
+
https: 'HTTPS approval required',
|
|
17
|
+
error: 'Connection error'
|
|
18
|
+
}
|
|
19
|
+
const render = (data) => {
|
|
20
|
+
const phase = data && data.phase
|
|
21
|
+
const label = labels[phase] || 'Connection status unavailable'
|
|
22
|
+
const pending = ['starting', 'login', 'machine_auth', 'https', 'setup'].includes(phase)
|
|
23
|
+
trigger.dataset.remoteStatus = phase === 'ready' ? 'ready' : phase === 'error' ? 'error' : pending ? 'pending' : phase === 'off' ? 'off' : 'unknown'
|
|
24
|
+
const description = `Remote — ${label}`
|
|
25
|
+
trigger.setAttribute('title', description)
|
|
26
|
+
trigger.setAttribute('aria-label', description)
|
|
27
|
+
trigger.setAttribute('data-tippy-content', description)
|
|
28
|
+
if (trigger._tippy) trigger._tippy.setContent(description)
|
|
29
|
+
|
|
30
|
+
const tab = document.querySelector('[data-main-sidebar-anywhere-tab]')
|
|
31
|
+
const badge = document.querySelector('[data-main-sidebar-anywhere-status]')
|
|
32
|
+
if (!tab || !badge) return
|
|
33
|
+
tab.setAttribute('title', description)
|
|
34
|
+
tab.setAttribute('aria-label', description)
|
|
35
|
+
badge.hidden = !labels[phase] || phase === 'setup'
|
|
36
|
+
badge.textContent = phase === 'ready' ? 'ON' : phase === 'off' || phase === 'error' ? 'OFF' : '…'
|
|
37
|
+
badge.dataset.state = phase === 'ready' ? 'on' : 'off'
|
|
38
|
+
}
|
|
39
|
+
document.addEventListener('pinokio:anywhere-status', (event) => {
|
|
40
|
+
revision += 1
|
|
41
|
+
const detail = event.detail || {}
|
|
42
|
+
payload = detail.payload || detail
|
|
43
|
+
render(payload)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const refresh = async () => {
|
|
47
|
+
if (inFlight || document.hidden) return
|
|
48
|
+
inFlight = true
|
|
49
|
+
const requestRevision = revision
|
|
50
|
+
const controller = new AbortController()
|
|
51
|
+
const timeout = setTimeout(() => controller.abort(), 10000)
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch('/anywhere/status?light=1', { cache: 'no-store', signal: controller.signal })
|
|
54
|
+
if (!response.ok) throw new Error(`Remote status failed: ${response.status}`)
|
|
55
|
+
const data = await response.json()
|
|
56
|
+
if (revision === requestRevision) {
|
|
57
|
+
payload = data
|
|
58
|
+
render(payload)
|
|
59
|
+
}
|
|
60
|
+
} catch (_) {
|
|
61
|
+
if (revision === requestRevision) {
|
|
62
|
+
payload = {}
|
|
63
|
+
render(payload)
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timeout)
|
|
67
|
+
inFlight = false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const init = () => {
|
|
71
|
+
if (payload) render(payload)
|
|
72
|
+
// The Remote page already polls and broadcasts its full status.
|
|
73
|
+
if (document.getElementById('anywhere-root')) return
|
|
74
|
+
refresh()
|
|
75
|
+
setInterval(refresh, 5000)
|
|
76
|
+
document.addEventListener('visibilitychange', refresh)
|
|
77
|
+
window.addEventListener('focus', refresh)
|
|
78
|
+
}
|
|
79
|
+
if (document.readyState === 'loading') {
|
|
80
|
+
document.addEventListener('DOMContentLoaded', init, { once: true })
|
|
81
|
+
} else {
|
|
82
|
+
init()
|
|
83
|
+
}
|
|
84
|
+
})()
|
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
<style>
|
|
2
|
+
.remote-nav-button {
|
|
3
|
+
position: relative;
|
|
4
|
+
width: 40px;
|
|
5
|
+
height: 30px;
|
|
6
|
+
min-width: 40px;
|
|
7
|
+
min-height: 30px;
|
|
8
|
+
padding: 0;
|
|
9
|
+
border-radius: 9px;
|
|
10
|
+
box-sizing: border-box;
|
|
11
|
+
}
|
|
12
|
+
.remote-nav-button__icon {
|
|
13
|
+
position: relative;
|
|
14
|
+
display: inline-flex;
|
|
15
|
+
width: 18px;
|
|
16
|
+
height: 18px;
|
|
17
|
+
}
|
|
18
|
+
.remote-nav-button__icon::before {
|
|
19
|
+
content: "";
|
|
20
|
+
position: absolute;
|
|
21
|
+
inset: 0;
|
|
22
|
+
background: currentColor;
|
|
23
|
+
-webkit-mask: url('/remote-control.svg') center / contain no-repeat;
|
|
24
|
+
mask: url('/remote-control.svg') center / contain no-repeat;
|
|
25
|
+
}
|
|
26
|
+
.remote-nav-button__dot {
|
|
27
|
+
position: absolute;
|
|
28
|
+
top: -4px;
|
|
29
|
+
right: -5px;
|
|
30
|
+
width: 7px;
|
|
31
|
+
height: 7px;
|
|
32
|
+
border: 1.5px solid var(--home-page-nav-bg, #fff);
|
|
33
|
+
border-radius: 999px;
|
|
34
|
+
background: #9aa1ad;
|
|
35
|
+
}
|
|
36
|
+
.remote-nav-button[data-remote-status="ready"] .remote-nav-button__dot {
|
|
37
|
+
background: #2db45c;
|
|
38
|
+
}
|
|
39
|
+
.remote-nav-button[data-remote-status="pending"] .remote-nav-button__dot {
|
|
40
|
+
background: #d98b1f;
|
|
41
|
+
}
|
|
42
|
+
.remote-nav-button[data-remote-status="error"] .remote-nav-button__dot {
|
|
43
|
+
background: #cf4545;
|
|
44
|
+
}
|
|
45
|
+
.remote-nav-button:focus-visible {
|
|
46
|
+
outline: 2px solid currentColor;
|
|
47
|
+
outline-offset: 2px;
|
|
48
|
+
}
|
|
49
|
+
body.dark .remote-nav-button__dot {
|
|
50
|
+
border-color: var(--home-page-nav-bg, #1b1c1d);
|
|
51
|
+
}
|
|
2
52
|
.home-server-popover {
|
|
3
53
|
position: relative;
|
|
4
54
|
display: inline-flex;
|
|
@@ -15,6 +65,8 @@
|
|
|
15
65
|
border-radius: 9px;
|
|
16
66
|
box-sizing: border-box;
|
|
17
67
|
}
|
|
68
|
+
.remote-nav-button:hover,
|
|
69
|
+
.remote-nav-button:focus-visible,
|
|
18
70
|
.home-server-popover__trigger:hover,
|
|
19
71
|
.home-server-popover__trigger:focus-visible,
|
|
20
72
|
.home-server-popover[data-home-server-open="true"] .home-server-popover__trigger {
|
|
@@ -512,6 +564,8 @@ body.dark .home-server-popover__panel {
|
|
|
512
564
|
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.3);
|
|
513
565
|
color: rgba(239, 242, 247, 0.92);
|
|
514
566
|
}
|
|
567
|
+
body.dark .remote-nav-button:hover,
|
|
568
|
+
body.dark .remote-nav-button:focus-visible,
|
|
515
569
|
body.dark .home-server-popover__trigger:hover,
|
|
516
570
|
body.dark .home-server-popover__trigger:focus-visible,
|
|
517
571
|
body.dark .home-server-popover[data-home-server-open="true"] .home-server-popover__trigger {
|
|
@@ -1265,3 +1319,4 @@ body.dark .home-server-popover__qr-close:focus-visible {
|
|
|
1265
1319
|
})
|
|
1266
1320
|
})()
|
|
1267
1321
|
</script>
|
|
1322
|
+
<script src="/remote-nav.js"></script>
|
|
@@ -122,43 +122,6 @@
|
|
|
122
122
|
|
|
123
123
|
initHomeServerStatusBadge();
|
|
124
124
|
|
|
125
|
-
const initAnywhereStatusBadge = () => {
|
|
126
|
-
if (!sidebar || window.__pinokioMainSidebarAnywhereStatusInit) return;
|
|
127
|
-
const tab = sidebar.querySelector("[data-main-sidebar-anywhere-tab]");
|
|
128
|
-
const badge = sidebar.querySelector("[data-main-sidebar-anywhere-status]");
|
|
129
|
-
if (!tab || !badge) return;
|
|
130
|
-
window.__pinokioMainSidebarAnywhereStatusInit = true;
|
|
131
|
-
const render = (data) => {
|
|
132
|
-
const phase = data && data.phase ? String(data.phase) : "off";
|
|
133
|
-
if (phase === "setup") {
|
|
134
|
-
badge.hidden = true;
|
|
135
|
-
delete badge.dataset.state;
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
const on = phase === "ready";
|
|
139
|
-
const label = on ? "ON" : (phase === "off" || phase === "error" ? "OFF" : "…");
|
|
140
|
-
badge.textContent = label;
|
|
141
|
-
badge.hidden = false;
|
|
142
|
-
badge.dataset.state = on ? "on" : "off";
|
|
143
|
-
tab.setAttribute("title", `Remote ${label}`);
|
|
144
|
-
};
|
|
145
|
-
document.addEventListener("pinokio:anywhere-status", (event) => {
|
|
146
|
-
const detail = event && event.detail ? event.detail : {};
|
|
147
|
-
render(detail.payload || detail);
|
|
148
|
-
});
|
|
149
|
-
fetch("/anywhere/status?light=1", { cache: "no-store" })
|
|
150
|
-
.then((response) => {
|
|
151
|
-
if (!response.ok) throw new Error(`Anywhere status failed: ${response.status}`);
|
|
152
|
-
return response.json();
|
|
153
|
-
})
|
|
154
|
-
.then(render)
|
|
155
|
-
.catch(() => {
|
|
156
|
-
badge.hidden = true;
|
|
157
|
-
delete badge.dataset.state;
|
|
158
|
-
});
|
|
159
|
-
};
|
|
160
|
-
initAnywhereStatusBadge();
|
|
161
|
-
|
|
162
125
|
if (body && sidebar && headerToggle && !headerToggle.dataset.mainSidebarHeaderToggle) {
|
|
163
126
|
headerToggle.dataset.mainSidebarHeaderToggle = "true";
|
|
164
127
|
headerToggle.classList.add("sidebar-toggle", "main-sidebar-header-toggle");
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<a class="btn2 remote-nav-button" href="/anywhere" data-remote-nav data-remote-status="loading" data-tippy-content="Remote — Checking connection" title="Remote — Checking connection" aria-label="Remote — Checking connection">
|
|
2
|
+
<span class="remote-nav-button__icon" aria-hidden="true">
|
|
3
|
+
<span class="remote-nav-button__dot"></span>
|
|
4
|
+
</span>
|
|
5
|
+
</a>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const fs = require('node:fs')
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const test = require('node:test')
|
|
5
|
+
const ejs = require('ejs')
|
|
6
|
+
const { JSDOM } = require('jsdom')
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(__dirname, '..')
|
|
9
|
+
const script = fs.readFileSync(path.join(root, 'server/public/remote-nav.js'), 'utf8')
|
|
10
|
+
const render = (file, data = {}) => ejs.render(fs.readFileSync(file, 'utf8'), data, { filename: file })
|
|
11
|
+
const tick = () => new Promise(resolve => setImmediate(resolve))
|
|
12
|
+
|
|
13
|
+
async function harness(t, { remotePage = false, fetch } = {}) {
|
|
14
|
+
const header = render(path.join(root, 'server/views/partials/app_navheader.ejs'), { agent: 'electron' })
|
|
15
|
+
const sidebar = render(path.join(root, 'server/views/partials/main_sidebar.ejs'), { selected: 'home' })
|
|
16
|
+
const dom = new JSDOM(`<!doctype html><body>${header}${sidebar}${remotePage ? '<div id="anywhere-root"></div>' : ''}</body>`, {
|
|
17
|
+
url: 'http://localhost:42000/home', runScripts: 'outside-only', pretendToBeVisual: true
|
|
18
|
+
})
|
|
19
|
+
t.after(() => dom.window.close())
|
|
20
|
+
const state = { payload: { phase: 'ready', enabled: true }, requests: [], intervals: [] }
|
|
21
|
+
dom.window.fetch = async (...args) => {
|
|
22
|
+
state.requests.push(args)
|
|
23
|
+
return fetch ? fetch(...args) : { ok: true, json: async () => state.payload }
|
|
24
|
+
}
|
|
25
|
+
dom.window.setInterval = (callback, delay) => {
|
|
26
|
+
state.intervals.push({ callback, delay })
|
|
27
|
+
return state.intervals.length
|
|
28
|
+
}
|
|
29
|
+
await tick()
|
|
30
|
+
dom.window.eval(script)
|
|
31
|
+
await tick()
|
|
32
|
+
const document = dom.window.document
|
|
33
|
+
const trigger = document.querySelector('[data-remote-nav]')
|
|
34
|
+
const badge = document.querySelector('[data-main-sidebar-anywhere-status]')
|
|
35
|
+
const dispatch = (payload) => document.dispatchEvent(new dom.window.CustomEvent('pinokio:anywhere-status', { detail: { payload } }))
|
|
36
|
+
return { dom, document, state, trigger, badge, dispatch }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('Remote links to the current-view page beside Home Server and shares live status with the sidebar', async t => {
|
|
40
|
+
const { document, state, trigger, badge, dispatch } = await harness(t)
|
|
41
|
+
assert.equal(document.querySelector('[data-home-server-popover]').nextElementSibling, trigger)
|
|
42
|
+
assert.equal(trigger.getAttribute('href'), '/anywhere')
|
|
43
|
+
assert.equal(trigger.getAttribute('target'), null)
|
|
44
|
+
assert.equal(trigger.dataset.remoteStatus, 'ready')
|
|
45
|
+
assert.equal(badge.textContent, 'ON')
|
|
46
|
+
assert.equal(state.requests.length, 1)
|
|
47
|
+
assert.equal(state.requests[0][0], '/anywhere/status?light=1')
|
|
48
|
+
assert.equal(state.requests[0][1].cache, 'no-store')
|
|
49
|
+
|
|
50
|
+
let tooltip
|
|
51
|
+
trigger._tippy = { setContent: value => { tooltip = value } }
|
|
52
|
+
const phases = [
|
|
53
|
+
['off', 'off', 'Off', 'OFF'],
|
|
54
|
+
['starting', 'pending', 'Connecting', '…'],
|
|
55
|
+
['login', 'pending', 'Sign-in required', '…'],
|
|
56
|
+
['machine_auth', 'pending', 'Device approval required', '…'],
|
|
57
|
+
['https', 'pending', 'HTTPS approval required', '…'],
|
|
58
|
+
['setup', 'pending', 'Setup required', '…'],
|
|
59
|
+
['error', 'error', 'Connection error', 'OFF'],
|
|
60
|
+
['ready', 'ready', 'Connected', 'ON']
|
|
61
|
+
]
|
|
62
|
+
for (const [phase, status, label, badgeLabel] of phases) {
|
|
63
|
+
dispatch({ phase, enabled: true })
|
|
64
|
+
assert.equal(trigger.dataset.remoteStatus, status)
|
|
65
|
+
assert.equal(trigger.getAttribute('aria-label'), `Remote — ${label}`)
|
|
66
|
+
assert.equal(tooltip, `Remote — ${label}`)
|
|
67
|
+
assert.equal(badge.textContent, badgeLabel)
|
|
68
|
+
assert.equal(badge.hidden, phase === 'setup')
|
|
69
|
+
assert.equal(document.querySelector('[data-main-sidebar-anywhere-tab]').getAttribute('aria-label'), `Remote — ${label}`)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('Remote status refreshes off-page, pauses when hidden, and recovers from a failed request', async t => {
|
|
74
|
+
let fail = false
|
|
75
|
+
const { dom, document, state, trigger, badge } = await harness(t, {
|
|
76
|
+
fetch: async () => {
|
|
77
|
+
if (fail) throw new Error('network unavailable')
|
|
78
|
+
return { ok: true, json: async () => ({ phase: 'off' }) }
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
assert.equal(state.intervals.length, 1)
|
|
82
|
+
assert.equal(state.intervals[0].delay, 5000)
|
|
83
|
+
fail = true
|
|
84
|
+
await state.intervals[0].callback()
|
|
85
|
+
assert.equal(trigger.dataset.remoteStatus, 'unknown')
|
|
86
|
+
assert.match(trigger.getAttribute('aria-label'), /status unavailable/)
|
|
87
|
+
assert.equal(badge.hidden, true)
|
|
88
|
+
Object.defineProperty(document, 'hidden', { configurable: true, value: true })
|
|
89
|
+
await state.intervals[0].callback()
|
|
90
|
+
assert.equal(state.requests.length, 2)
|
|
91
|
+
fail = false
|
|
92
|
+
Object.defineProperty(document, 'hidden', { configurable: true, value: false })
|
|
93
|
+
document.dispatchEvent(new dom.window.Event('visibilitychange'))
|
|
94
|
+
await tick()
|
|
95
|
+
assert.equal(trigger.dataset.remoteStatus, 'off')
|
|
96
|
+
assert.equal(badge.hidden, false)
|
|
97
|
+
assert.equal(badge.textContent, 'OFF')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('an older status request cannot overwrite a Remote page action; duplicate initialization does not add polling', async t => {
|
|
101
|
+
let resolveResponse
|
|
102
|
+
const response = new Promise(resolve => { resolveResponse = resolve })
|
|
103
|
+
const { dom, state, trigger, dispatch } = await harness(t, { fetch: () => response })
|
|
104
|
+
await state.intervals[0].callback()
|
|
105
|
+
assert.equal(state.requests.length, 1)
|
|
106
|
+
dispatch({ phase: 'off' })
|
|
107
|
+
resolveResponse({ ok: true, json: async () => ({ phase: 'ready' }) })
|
|
108
|
+
await tick()
|
|
109
|
+
assert.equal(trigger.dataset.remoteStatus, 'off')
|
|
110
|
+
dom.window.eval(script)
|
|
111
|
+
assert.equal(state.intervals.length, 1)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('the Remote page uses its existing status broadcasts without another poller', async t => {
|
|
115
|
+
const { state, trigger, badge, dispatch } = await harness(t, { remotePage: true })
|
|
116
|
+
assert.equal(state.requests.length, 0)
|
|
117
|
+
assert.equal(state.intervals.length, 0)
|
|
118
|
+
dispatch({ phase: 'ready' })
|
|
119
|
+
assert.equal(trigger.dataset.remoteStatus, 'ready')
|
|
120
|
+
assert.equal(badge.textContent, 'ON')
|
|
121
|
+
dispatch({ phase: 'login', enabled: true })
|
|
122
|
+
assert.equal(trigger.dataset.remoteStatus, 'pending')
|
|
123
|
+
assert.equal(badge.textContent, '…')
|
|
124
|
+
})
|