@signalk/app-dock 1.4.0 → 1.4.1
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/README.md +23 -12
- package/app-icon.svg +21 -0
- package/dist/plugin/index.js +141 -0
- package/dist/plugin/schema.js +58 -0
- package/package.json +32 -14
- package/public/config.html +4 -437
- package/public/config.js +22 -0
- package/public/configpanel/PluginConfigurationPanel.js +1 -0
- package/public/configpanel/_virtual_mf-localSharedImportMap___mfe_internal___signalk_app_dock__mf_owner__1-_R-vaMcG.js +1 -0
- package/public/configpanel/dist-D1jGdgWH.js +4 -0
- package/public/configpanel/hostInit-D-z9PzRy.js +1 -0
- package/public/configpanel/pendingShares-BqCOmgFj.js +1 -0
- package/public/configpanel/vite-preload-helper-DHhhL8Zf.js +1 -0
- package/public/dock.css +1 -610
- package/public/dock.js +1 -1129
- package/public/index.html +6 -6
- package/public/remoteEntry.js +1 -1
- package/plugin/index.js +0 -273
- package/public/540.js +0 -2
- package/public/540.js.LICENSE.txt +0 -9
- package/public/805.js +0 -1
- package/public/main.js +0 -1
package/public/dock.js
CHANGED
|
@@ -1,1129 +1 @@
|
|
|
1
|
-
;(async function DockApp() {
|
|
2
|
-
'use strict'
|
|
3
|
-
|
|
4
|
-
// Bail out if we're running inside another iframe. This prevents the
|
|
5
|
-
// dock-in-dock loop when the dock is opened via its own Settings tile /
|
|
6
|
-
// the admin UI's Webapps tab / any other embed path. Same-origin redirect
|
|
7
|
-
// the top window to the dock URL so the user ends up on a clean
|
|
8
|
-
// standalone dock instead of a nested instance. The outer dock grants
|
|
9
|
-
// allow-top-navigation-by-user-activation in its iframe sandbox, so this
|
|
10
|
-
// works as long as the user activated the nested load by tapping.
|
|
11
|
-
if (window.self !== window.top) {
|
|
12
|
-
const dockUrl = window.location.href
|
|
13
|
-
try {
|
|
14
|
-
window.top.location.replace(dockUrl)
|
|
15
|
-
} catch {
|
|
16
|
-
// Cross-origin parent or missing top-nav sandbox permission.
|
|
17
|
-
}
|
|
18
|
-
// If replace() was blocked, top-nav sandbox permission was missing AND
|
|
19
|
-
// the user hadn't tapped recently. Show a tappable escape hatch — the
|
|
20
|
-
// click is itself a user activation, so the link succeeds even when
|
|
21
|
-
// only allow-top-navigation-by-user-activation is present.
|
|
22
|
-
document.body.innerHTML =
|
|
23
|
-
'<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;height:100vh;color:#fff;background:#000;font-family:-apple-system,BlinkMacSystemFont,sans-serif;text-align:center;padding:16px">' +
|
|
24
|
-
'<img src="app-icon.svg" alt="App Dock" style="width:72px;height:72px;border-radius:16px;opacity:0.85" />' +
|
|
25
|
-
'<a href="' +
|
|
26
|
-
dockUrl +
|
|
27
|
-
'" target="_top" style="color:#fff;text-decoration:underline;font-size:16px">Open App Dock</a>' +
|
|
28
|
-
'</div>'
|
|
29
|
-
return
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const DEFAULTS = {
|
|
33
|
-
position: 'bottom',
|
|
34
|
-
iframeMode: 'keep-alive',
|
|
35
|
-
iconSize: 56,
|
|
36
|
-
magnification: true,
|
|
37
|
-
magnificationScale: 1.7,
|
|
38
|
-
showNightModeButton: true,
|
|
39
|
-
showFullscreenButton: false,
|
|
40
|
-
showExitButton: false,
|
|
41
|
-
apps: []
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Fallback app list for a visitor who is not an admin when the plugin's
|
|
45
|
-
// /settings refuses them. signalk-server before 2.31 keeps every plugin
|
|
46
|
-
// route admin-only, and any version refuses a visitor who is not signed in
|
|
47
|
-
// unless it allows read-only access. Mirrors the server-side DEFAULT_APPS
|
|
48
|
-
// in plugin/index.js so the demo at demo.signalk.org behaves sensibly for
|
|
49
|
-
// non-admin visitors.
|
|
50
|
-
const NO_ADMIN_DEFAULTS = {
|
|
51
|
-
showNightModeButton: true,
|
|
52
|
-
apps: [
|
|
53
|
-
{
|
|
54
|
-
enabled: true,
|
|
55
|
-
autostart: true,
|
|
56
|
-
url: '/@signalk/freeboard-sk/',
|
|
57
|
-
label: 'Freeboard-SK',
|
|
58
|
-
icon: '/@signalk/freeboard-sk/assets/icons/icon-72x72.png',
|
|
59
|
-
color: ''
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
enabled: true,
|
|
63
|
-
autostart: false,
|
|
64
|
-
url: '/@halos-org/skip/',
|
|
65
|
-
label: 'Skip',
|
|
66
|
-
icon: '/@halos-org/skip/assets/icon-72x72.png',
|
|
67
|
-
color: ''
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
enabled: true,
|
|
71
|
-
autostart: false,
|
|
72
|
-
url: '/admin/',
|
|
73
|
-
label: 'Settings',
|
|
74
|
-
icon: '/@signalk/app-dock/icon-settings.svg',
|
|
75
|
-
color: '#78788c'
|
|
76
|
-
}
|
|
77
|
-
]
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ─── Detect access level ────────────────────────────────────────────────────
|
|
81
|
-
// /skServer/loginStatus is public. Only an admin can read the plugin's
|
|
82
|
-
// /config (signalk-server keeps it admin-only), so every other visitor has
|
|
83
|
-
// noAdminAccess and reads the dock from /settings instead. canWrite marks
|
|
84
|
-
// the users signalk-server lets write: admins and readwrite users.
|
|
85
|
-
// When authenticationRequired is false (no-auth / dummy-security setups),
|
|
86
|
-
// /plugins/* is reachable without a login, so treat the visitor as
|
|
87
|
-
// full-access instead of falling through to the hardcoded fallback list.
|
|
88
|
-
// Try /skServer/loginStatus first, fall back to the legacy /loginStatus.
|
|
89
|
-
let noAdminAccess = false
|
|
90
|
-
let canWrite = true
|
|
91
|
-
for (const url of ['/skServer/loginStatus', '/loginStatus']) {
|
|
92
|
-
try {
|
|
93
|
-
const res = await fetch(url)
|
|
94
|
-
if (res.ok) {
|
|
95
|
-
const data = await res.json()
|
|
96
|
-
const authOff = data.authenticationRequired === false
|
|
97
|
-
const level = data.status === 'loggedIn' ? data.userLevel : undefined
|
|
98
|
-
noAdminAccess = !authOff && level !== 'admin'
|
|
99
|
-
canWrite = authOff || level === 'admin' || level === 'readwrite'
|
|
100
|
-
break
|
|
101
|
-
}
|
|
102
|
-
} catch {
|
|
103
|
-
// try next URL
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// The dock as the plugin serves it on /settings: the plugin configuration
|
|
108
|
-
// with the enabled apps resolved. The plugin opens this route to every user
|
|
109
|
-
// with read access on signalk-server 2.31 and later. Null when the route
|
|
110
|
-
// refuses this visitor or answers without an app list.
|
|
111
|
-
async function fetchSharedConfig() {
|
|
112
|
-
try {
|
|
113
|
-
const res = await fetch('/plugins/signalk-app-dock/settings')
|
|
114
|
-
if (!res.ok) return null
|
|
115
|
-
const data = await res.json()
|
|
116
|
-
return Array.isArray(data.apps) ? { ...DEFAULTS, ...data } : null
|
|
117
|
-
} catch {
|
|
118
|
-
return null
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ─── Load config from plugin endpoints ───────────────────────────────────────
|
|
123
|
-
let cfg = { ...DEFAULTS }
|
|
124
|
-
// Whether the dock keeps calling the plugin's routes after loading: always
|
|
125
|
-
// for an admin, and for anyone else once /settings has served them the dock
|
|
126
|
-
// (the plugin opens /settings and /mode to the same users).
|
|
127
|
-
let pluginReachable = !noAdminAccess
|
|
128
|
-
if (noAdminAccess) {
|
|
129
|
-
const shared = await fetchSharedConfig()
|
|
130
|
-
pluginReachable = shared !== null
|
|
131
|
-
cfg = shared || { ...DEFAULTS, ...NO_ADMIN_DEFAULTS }
|
|
132
|
-
} else {
|
|
133
|
-
try {
|
|
134
|
-
const [configRes, settingsRes] = await Promise.all([
|
|
135
|
-
fetch('/plugins/signalk-app-dock/config'),
|
|
136
|
-
fetch('/plugins/signalk-app-dock/settings')
|
|
137
|
-
])
|
|
138
|
-
if (configRes.ok) {
|
|
139
|
-
const data = await configRes.json()
|
|
140
|
-
const pluginCfg = data.configuration || data
|
|
141
|
-
cfg = { ...DEFAULTS, ...pluginCfg }
|
|
142
|
-
}
|
|
143
|
-
if (settingsRes.ok) {
|
|
144
|
-
const data = await settingsRes.json()
|
|
145
|
-
if (Array.isArray(data.apps) && data.apps.length > 0) {
|
|
146
|
-
cfg.apps = data.apps
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
} catch (e) {
|
|
150
|
-
console.warn('[Dock] Could not load config, using defaults.', e)
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (!Array.isArray(cfg.apps) || cfg.apps.length === 0) {
|
|
155
|
-
console.warn('[Dock] No apps configured — open Plugin Config to add webapps.')
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ─── DOM refs ────────────────────────────────────────────────────────────────
|
|
159
|
-
const $iframeContainer = document.getElementById('iframe-container')
|
|
160
|
-
const $backdrop = document.getElementById('dock-backdrop')
|
|
161
|
-
const $dock = document.getElementById('dock')
|
|
162
|
-
const $dockInner = document.getElementById('dock-inner')
|
|
163
|
-
const $loadingOverlay = document.getElementById('loading-overlay')
|
|
164
|
-
const $loadingLabel = document.getElementById('loading-label')
|
|
165
|
-
const $idleHint = document.getElementById('idle-hint')
|
|
166
|
-
const $idleHintText = document.getElementById('idle-hint-text')
|
|
167
|
-
const $dockHandle = document.getElementById('dock-handle')
|
|
168
|
-
|
|
169
|
-
// ─── State ───────────────────────────────────────────────────────────────────
|
|
170
|
-
let dockVisible = false
|
|
171
|
-
const iframes = {}
|
|
172
|
-
|
|
173
|
-
// ─── Night mode state ────────────────────────────────────────────────────────
|
|
174
|
-
let currentMode = 'day'
|
|
175
|
-
// The button reads the mode from the plugin's /mode and switches it with a
|
|
176
|
-
// PUT that signalk-server accepts from admins and readwrite users only, so
|
|
177
|
-
// only visitors who can do both get the button.
|
|
178
|
-
const nightModeUsable = pluginReachable && canWrite
|
|
179
|
-
|
|
180
|
-
async function fetchCurrentMode() {
|
|
181
|
-
if (!pluginReachable) return
|
|
182
|
-
try {
|
|
183
|
-
const res = await fetch('/plugins/signalk-app-dock/mode')
|
|
184
|
-
if (res.ok) {
|
|
185
|
-
const data = await res.json()
|
|
186
|
-
currentMode = data.value || 'day'
|
|
187
|
-
updateNightModeIcon()
|
|
188
|
-
}
|
|
189
|
-
} catch (e) {
|
|
190
|
-
console.warn('[Dock] Could not fetch environment.mode', e)
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function updateNightModeIcon() {
|
|
195
|
-
const icon = document.querySelector('.dock-item-nightmode .dock-icon')
|
|
196
|
-
if (!icon) return
|
|
197
|
-
icon.textContent = currentMode === 'night' ? '\u{1F319}' : '\u2600\uFE0F'
|
|
198
|
-
const label = document.querySelector('.dock-item-nightmode .dock-label')
|
|
199
|
-
if (label) label.textContent = currentMode === 'night' ? 'Day mode' : 'Night mode'
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
async function toggleNightMode() {
|
|
203
|
-
if (!nightModeUsable) return
|
|
204
|
-
await fetchCurrentMode()
|
|
205
|
-
const newMode = currentMode === 'night' ? 'day' : 'night'
|
|
206
|
-
try {
|
|
207
|
-
const res = await fetch('/signalk/v1/api/vessels/self/environment/mode', {
|
|
208
|
-
method: 'PUT',
|
|
209
|
-
headers: { 'Content-Type': 'application/json' },
|
|
210
|
-
body: JSON.stringify({ value: newMode })
|
|
211
|
-
})
|
|
212
|
-
if (res.ok) {
|
|
213
|
-
currentMode = newMode
|
|
214
|
-
updateNightModeIcon()
|
|
215
|
-
}
|
|
216
|
-
} catch (e) {
|
|
217
|
-
console.warn('[Dock] Failed to toggle mode', e)
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// ─── Fullscreen toggle ───────────────────────────────────────────────────────
|
|
222
|
-
// Targets documentElement so the iframe (a descendant) goes fullscreen with
|
|
223
|
-
// the dock. Targeting body or the iframe directly would either fail or make
|
|
224
|
-
// the iframe the fullscreen element, which breaks gesture passthrough.
|
|
225
|
-
function fullscreenSupported() {
|
|
226
|
-
return !!(document.fullscreenEnabled && document.documentElement.requestFullscreen)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function isFullscreen() {
|
|
230
|
-
return !!document.fullscreenElement
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function toggleFullscreen() {
|
|
234
|
-
if (!fullscreenSupported()) return
|
|
235
|
-
if (isFullscreen()) {
|
|
236
|
-
document.exitFullscreen().catch((e) => console.warn('[Dock] exitFullscreen', e))
|
|
237
|
-
} else {
|
|
238
|
-
document.documentElement.requestFullscreen().catch((e) => console.warn('[Dock] requestFullscreen', e))
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function updateFullscreenIcon() {
|
|
243
|
-
const icon = document.querySelector('.dock-item-fullscreen .dock-icon')
|
|
244
|
-
if (!icon) return
|
|
245
|
-
icon.textContent = isFullscreen() ? '\u2922' : '\u26F6'
|
|
246
|
-
const label = document.querySelector('.dock-item-fullscreen .dock-label')
|
|
247
|
-
if (label) label.textContent = isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// ─── Apply dock position class & alignment ───────────────────────────────────
|
|
251
|
-
const pos = cfg.position
|
|
252
|
-
$dock.classList.add(`pos-${pos}`)
|
|
253
|
-
$dockInner.classList.add(`align-${pos}`)
|
|
254
|
-
$dockHandle.classList.add(`pos-${pos}`)
|
|
255
|
-
|
|
256
|
-
const isVertical = pos === 'left' || pos === 'right'
|
|
257
|
-
if (isVertical) $dockInner.classList.add('layout-vertical')
|
|
258
|
-
|
|
259
|
-
// ─── Magnification constants ─────────────────────────────────────────────────
|
|
260
|
-
const MAG_ENABLED = cfg.magnification !== false
|
|
261
|
-
const MAG_SCALE = Math.max(1, Math.min(cfg.magnificationScale || 1.7, 2.5))
|
|
262
|
-
const MAG_RADIUS = cfg.iconSize * 3.5
|
|
263
|
-
|
|
264
|
-
// ─── Welcome screen ──────────────────────────────────────────────────────────
|
|
265
|
-
function setIdleHint() {
|
|
266
|
-
const $version = document.getElementById('idle-version')
|
|
267
|
-
if ($version) {
|
|
268
|
-
fetch('/skServer/webapps')
|
|
269
|
-
.then((r) => r.json())
|
|
270
|
-
.then((apps) => {
|
|
271
|
-
const me = apps.find((a) => a.name === '@signalk/app-dock')
|
|
272
|
-
if (me) $version.textContent = 'v' + me.version
|
|
273
|
-
})
|
|
274
|
-
.catch(() => {})
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (!cfg.apps || cfg.apps.length === 0) {
|
|
278
|
-
if (noAdminAccess) {
|
|
279
|
-
$idleHintText.innerHTML =
|
|
280
|
-
'App Dock can only be customised by an administrator.<br>' +
|
|
281
|
-
'Ask an admin to set up apps in <strong>Plugin Config</strong>.'
|
|
282
|
-
} else {
|
|
283
|
-
$idleHintText.innerHTML =
|
|
284
|
-
'No apps configured yet.<br>' +
|
|
285
|
-
'Open <strong>Admin UI \u2192 Plugin Config \u2192 App Dock</strong><br>' +
|
|
286
|
-
'and click <strong>Discover Installed Webapps</strong> to get started.'
|
|
287
|
-
}
|
|
288
|
-
return
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const footer = noAdminAccess
|
|
292
|
-
? '<br><br><span style="font-size:12px;opacity:0.6">Customisation is admin-only</span>'
|
|
293
|
-
: '<br><br><span style="font-size:12px;opacity:0.6">Configure in Admin UI \u2192 Plugin Config \u2192 App Dock</span>'
|
|
294
|
-
$idleHintText.innerHTML = 'Double-tap <strong>anywhere</strong> to open the dock' + footer
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function hideIdleHint() {
|
|
298
|
-
if ($idleHint) $idleHint.classList.add('hidden')
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// ─── Tooltip direction based on dock edge ────────────────────────────────────
|
|
302
|
-
function labelClass() {
|
|
303
|
-
switch (pos) {
|
|
304
|
-
case 'top':
|
|
305
|
-
return 'dock-label-below'
|
|
306
|
-
case 'left':
|
|
307
|
-
return 'dock-label-right'
|
|
308
|
-
case 'right':
|
|
309
|
-
return 'dock-label-left'
|
|
310
|
-
default:
|
|
311
|
-
return ''
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// ─── Utility item builder ─────────────────────────────────────────────────────
|
|
316
|
-
function createUtilityItem(className, icon, label, onClick) {
|
|
317
|
-
const sz = cfg.iconSize
|
|
318
|
-
const radius = Math.round(sz * 0.25) + 'px'
|
|
319
|
-
const lblCls = labelClass()
|
|
320
|
-
|
|
321
|
-
const $item = document.createElement('div')
|
|
322
|
-
$item.className = 'dock-item dock-item-utility ' + className
|
|
323
|
-
|
|
324
|
-
const $icon = document.createElement('div')
|
|
325
|
-
$icon.className = 'dock-icon dock-icon-utility'
|
|
326
|
-
$icon.style.width = sz + 'px'
|
|
327
|
-
$icon.style.height = sz + 'px'
|
|
328
|
-
$icon.style.borderRadius = radius
|
|
329
|
-
$icon.style.fontSize = Math.round(sz * 0.48) + 'px'
|
|
330
|
-
$icon.textContent = icon
|
|
331
|
-
|
|
332
|
-
const $dot = document.createElement('div')
|
|
333
|
-
$dot.className = 'dock-dot'
|
|
334
|
-
|
|
335
|
-
const $label = document.createElement('div')
|
|
336
|
-
$label.className = 'dock-label' + (lblCls ? ' ' + lblCls : '')
|
|
337
|
-
$label.textContent = label
|
|
338
|
-
|
|
339
|
-
$item.appendChild($icon)
|
|
340
|
-
$item.appendChild($dot)
|
|
341
|
-
$item.appendChild($label)
|
|
342
|
-
|
|
343
|
-
$item.addEventListener('touchstart', () => $item.classList.add('pressing'), { passive: true })
|
|
344
|
-
$item.addEventListener('touchend', () => $item.classList.remove('pressing'), { passive: true })
|
|
345
|
-
$item.addEventListener('touchcancel', () => $item.classList.remove('pressing'), { passive: true })
|
|
346
|
-
|
|
347
|
-
$item.addEventListener('click', (e) => {
|
|
348
|
-
if (magScrubbed) {
|
|
349
|
-
e.preventDefault()
|
|
350
|
-
return
|
|
351
|
-
}
|
|
352
|
-
onClick()
|
|
353
|
-
})
|
|
354
|
-
|
|
355
|
-
return $item
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function createSeparator() {
|
|
359
|
-
const $sep = document.createElement('div')
|
|
360
|
-
$sep.className = 'dock-separator'
|
|
361
|
-
return $sep
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// ─── Build dock items ─────────────────────────────────────────────────────────
|
|
365
|
-
function buildDock() {
|
|
366
|
-
$dockInner.innerHTML = ''
|
|
367
|
-
const sz = cfg.iconSize
|
|
368
|
-
const radius = Math.round(sz * 0.25) + 'px'
|
|
369
|
-
const lblCls = labelClass()
|
|
370
|
-
|
|
371
|
-
const showNightMode = cfg.showNightModeButton && nightModeUsable
|
|
372
|
-
if (showNightMode) {
|
|
373
|
-
$dockInner.appendChild(createUtilityItem('dock-item-nightmode', '\u2600\uFE0F', 'Night mode', toggleNightMode))
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
if (cfg.showFullscreenButton && fullscreenSupported()) {
|
|
377
|
-
$dockInner.appendChild(createUtilityItem('dock-item-fullscreen', '\u26F6', 'Fullscreen', toggleFullscreen))
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
if (showNightMode || (cfg.showFullscreenButton && fullscreenSupported())) {
|
|
381
|
-
$dockInner.appendChild(createSeparator())
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
cfg.apps.forEach((app, i) => {
|
|
385
|
-
const $item = document.createElement('div')
|
|
386
|
-
$item.className = 'dock-item'
|
|
387
|
-
$item.dataset.index = i
|
|
388
|
-
|
|
389
|
-
const $icon = document.createElement('div')
|
|
390
|
-
$icon.className = 'dock-icon'
|
|
391
|
-
$icon.style.width = sz + 'px'
|
|
392
|
-
$icon.style.height = sz + 'px'
|
|
393
|
-
$icon.style.borderRadius = radius
|
|
394
|
-
if (app.color) $icon.style.background = app.color
|
|
395
|
-
|
|
396
|
-
const iconVal = app.icon || ''
|
|
397
|
-
if (iconVal.startsWith('/') || iconVal.startsWith('http')) {
|
|
398
|
-
const $img = document.createElement('img')
|
|
399
|
-
$img.src = iconVal
|
|
400
|
-
$img.alt = app.label
|
|
401
|
-
$img.onerror = () => {
|
|
402
|
-
$icon.removeChild($img)
|
|
403
|
-
$icon.style.fontSize = Math.round(sz * 0.42) + 'px'
|
|
404
|
-
$icon.textContent = app.label.slice(0, 2).toUpperCase()
|
|
405
|
-
}
|
|
406
|
-
$icon.appendChild($img)
|
|
407
|
-
} else {
|
|
408
|
-
$icon.style.fontSize = Math.round(sz * 0.48) + 'px'
|
|
409
|
-
$icon.textContent = iconVal || app.label.slice(0, 2).toUpperCase()
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
const $dot = document.createElement('div')
|
|
413
|
-
$dot.className = 'dock-dot'
|
|
414
|
-
|
|
415
|
-
const $label = document.createElement('div')
|
|
416
|
-
$label.className = 'dock-label' + (lblCls ? ' ' + lblCls : '')
|
|
417
|
-
$label.textContent = app.label
|
|
418
|
-
|
|
419
|
-
$item.appendChild($icon)
|
|
420
|
-
$item.appendChild($dot)
|
|
421
|
-
$item.appendChild($label)
|
|
422
|
-
|
|
423
|
-
$item.addEventListener('touchstart', () => $item.classList.add('pressing'), { passive: true })
|
|
424
|
-
$item.addEventListener('touchend', () => $item.classList.remove('pressing'), { passive: true })
|
|
425
|
-
$item.addEventListener('touchcancel', () => $item.classList.remove('pressing'), { passive: true })
|
|
426
|
-
|
|
427
|
-
$item.addEventListener('click', (e) => {
|
|
428
|
-
if (magScrubbed) {
|
|
429
|
-
e.preventDefault()
|
|
430
|
-
return
|
|
431
|
-
}
|
|
432
|
-
switchToApp(i)
|
|
433
|
-
})
|
|
434
|
-
|
|
435
|
-
$dockInner.appendChild($item)
|
|
436
|
-
})
|
|
437
|
-
|
|
438
|
-
if (cfg.showExitButton) {
|
|
439
|
-
$dockInner.appendChild(createSeparator())
|
|
440
|
-
$dockInner.appendChild(
|
|
441
|
-
createUtilityItem('dock-item-exit', '\u2715', 'Exit to Admin', () => {
|
|
442
|
-
window.location.href = '/admin/'
|
|
443
|
-
})
|
|
444
|
-
)
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if (cfg.showNightModeButton) updateNightModeIcon()
|
|
448
|
-
if (cfg.showFullscreenButton && fullscreenSupported()) updateFullscreenIcon()
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// ─── Magnification ───────────────────────────────────────────────────────────
|
|
452
|
-
let magRafPending = false
|
|
453
|
-
let magScrubbed = false
|
|
454
|
-
let magTouchStartAxis = 0
|
|
455
|
-
|
|
456
|
-
function applyMagnification(pointerPos) {
|
|
457
|
-
const items = $dockInner.querySelectorAll('.dock-item')
|
|
458
|
-
let closestIdx = -1
|
|
459
|
-
let closestDist = Infinity
|
|
460
|
-
|
|
461
|
-
items.forEach((item, i) => {
|
|
462
|
-
const icon = item.querySelector('.dock-icon')
|
|
463
|
-
const rect = item.getBoundingClientRect()
|
|
464
|
-
const center = isVertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2
|
|
465
|
-
const p = isVertical ? pointerPos.y : pointerPos.x
|
|
466
|
-
|
|
467
|
-
const dist = Math.abs(p - center)
|
|
468
|
-
if (dist < closestDist) {
|
|
469
|
-
closestDist = dist
|
|
470
|
-
closestIdx = i
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const ratio = Math.min(dist / MAG_RADIUS, 1)
|
|
474
|
-
const scale = 1 + (MAG_SCALE - 1) * (1 - ratio * ratio)
|
|
475
|
-
const newSize = Math.round(cfg.iconSize * scale)
|
|
476
|
-
|
|
477
|
-
icon.classList.remove('settling')
|
|
478
|
-
icon.style.width = newSize + 'px'
|
|
479
|
-
icon.style.height = newSize + 'px'
|
|
480
|
-
icon.style.borderRadius = Math.round(newSize * 0.25) + 'px'
|
|
481
|
-
|
|
482
|
-
if (icon.querySelector('img')) {
|
|
483
|
-
// image icons scale via width/height, fontSize not needed
|
|
484
|
-
} else {
|
|
485
|
-
icon.style.fontSize = Math.round(newSize * 0.48) + 'px'
|
|
486
|
-
}
|
|
487
|
-
})
|
|
488
|
-
|
|
489
|
-
items.forEach((item, i) => {
|
|
490
|
-
item.classList.toggle('mag-closest', i === closestIdx)
|
|
491
|
-
})
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function resetMagnification() {
|
|
495
|
-
const items = $dockInner.querySelectorAll('.dock-item')
|
|
496
|
-
const sz = cfg.iconSize
|
|
497
|
-
|
|
498
|
-
items.forEach((item) => {
|
|
499
|
-
const icon = item.querySelector('.dock-icon')
|
|
500
|
-
icon.classList.add('settling')
|
|
501
|
-
icon.style.width = sz + 'px'
|
|
502
|
-
icon.style.height = sz + 'px'
|
|
503
|
-
icon.style.borderRadius = Math.round(sz * 0.25) + 'px'
|
|
504
|
-
|
|
505
|
-
if (!icon.querySelector('img')) {
|
|
506
|
-
icon.style.fontSize = Math.round(sz * 0.48) + 'px'
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
item.classList.remove('mag-closest')
|
|
510
|
-
})
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (MAG_ENABLED) {
|
|
514
|
-
$dockInner.addEventListener('mousemove', (e) => {
|
|
515
|
-
if (magRafPending) return
|
|
516
|
-
magRafPending = true
|
|
517
|
-
const pos = { x: e.clientX, y: e.clientY }
|
|
518
|
-
requestAnimationFrame(() => {
|
|
519
|
-
applyMagnification(pos)
|
|
520
|
-
magRafPending = false
|
|
521
|
-
})
|
|
522
|
-
})
|
|
523
|
-
|
|
524
|
-
$dockInner.addEventListener('mouseleave', () => {
|
|
525
|
-
magRafPending = false
|
|
526
|
-
resetMagnification()
|
|
527
|
-
})
|
|
528
|
-
|
|
529
|
-
$dockInner.addEventListener(
|
|
530
|
-
'touchstart',
|
|
531
|
-
(e) => {
|
|
532
|
-
const t = e.touches[0]
|
|
533
|
-
magScrubbed = false
|
|
534
|
-
magTouchStartAxis = isVertical ? t.clientY : t.clientX
|
|
535
|
-
},
|
|
536
|
-
{ passive: true }
|
|
537
|
-
)
|
|
538
|
-
|
|
539
|
-
$dockInner.addEventListener(
|
|
540
|
-
'touchmove',
|
|
541
|
-
(e) => {
|
|
542
|
-
const t = e.touches[0]
|
|
543
|
-
const current = isVertical ? t.clientY : t.clientX
|
|
544
|
-
if (Math.abs(current - magTouchStartAxis) > 10) {
|
|
545
|
-
magScrubbed = true
|
|
546
|
-
}
|
|
547
|
-
if (magRafPending) return
|
|
548
|
-
magRafPending = true
|
|
549
|
-
const pos = { x: t.clientX, y: t.clientY }
|
|
550
|
-
requestAnimationFrame(() => {
|
|
551
|
-
applyMagnification(pos)
|
|
552
|
-
magRafPending = false
|
|
553
|
-
})
|
|
554
|
-
},
|
|
555
|
-
{ passive: true }
|
|
556
|
-
)
|
|
557
|
-
|
|
558
|
-
$dockInner.addEventListener(
|
|
559
|
-
'touchend',
|
|
560
|
-
() => {
|
|
561
|
-
magRafPending = false
|
|
562
|
-
resetMagnification()
|
|
563
|
-
setTimeout(() => {
|
|
564
|
-
magScrubbed = false
|
|
565
|
-
}, 50)
|
|
566
|
-
},
|
|
567
|
-
{ passive: true }
|
|
568
|
-
)
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// ─── Show / hide dock ────────────────────────────────────────────────────────
|
|
572
|
-
let dockShownAt = 0
|
|
573
|
-
const DOCK_CLICK_IGNORE_MS = 500
|
|
574
|
-
|
|
575
|
-
function showDock() {
|
|
576
|
-
if (dockVisible) return
|
|
577
|
-
dockVisible = true
|
|
578
|
-
dockShownAt = Date.now()
|
|
579
|
-
$dock.classList.add('visible')
|
|
580
|
-
$backdrop.classList.add('visible')
|
|
581
|
-
updateHandle()
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function hideDock() {
|
|
585
|
-
if (!dockVisible) return
|
|
586
|
-
if ($tourOverlay && $tourOverlay.classList.contains('visible')) return
|
|
587
|
-
dockVisible = false
|
|
588
|
-
$dock.classList.remove('visible')
|
|
589
|
-
$backdrop.classList.remove('visible')
|
|
590
|
-
resetMagnification()
|
|
591
|
-
updateHandle()
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
$backdrop.addEventListener('click', (e) => {
|
|
595
|
-
// Windows touch synthesizes a click on whatever's under the finger after
|
|
596
|
-
// pointerdown. If that click lands on the just-shown backdrop, it would
|
|
597
|
-
// immediately close the dock. Ignore clicks within a short window after
|
|
598
|
-
// the dock opens.
|
|
599
|
-
if (Date.now() - dockShownAt < DOCK_CLICK_IGNORE_MS) {
|
|
600
|
-
e.stopPropagation()
|
|
601
|
-
return
|
|
602
|
-
}
|
|
603
|
-
hideDock()
|
|
604
|
-
})
|
|
605
|
-
|
|
606
|
-
// ─── Loading overlay helpers ──────────────────────────────────────────────────
|
|
607
|
-
function showLoading(label) {
|
|
608
|
-
$loadingLabel.textContent = `Loading ${label}\u2026`
|
|
609
|
-
$loadingOverlay.classList.add('visible')
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
function hideLoading() {
|
|
613
|
-
$loadingOverlay.classList.remove('visible')
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// ─── Switch to app ────────────────────────────────────────────────────────────
|
|
617
|
-
// The server's admin UI leads anywhere: into a webapp, a plugin's page or
|
|
618
|
-
// another of its own screens. Its tile opens it fresh every time, at the
|
|
619
|
-
// URL the tile points to, so it never resumes wherever it was left.
|
|
620
|
-
function opensFresh(app) {
|
|
621
|
-
try {
|
|
622
|
-
const url = new URL(app.url, window.location.href)
|
|
623
|
-
return url.origin === window.location.origin && /^\/admin(\/|$)/.test(url.pathname)
|
|
624
|
-
} catch {
|
|
625
|
-
return false
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// Reloads the frame at the page it is on now. A frame on another origin
|
|
630
|
-
// refuses access to its location, so it starts over at the app's URL.
|
|
631
|
-
function reloadFrame($frame, app) {
|
|
632
|
-
showLoading(app.label)
|
|
633
|
-
$frame.addEventListener('load', hideLoading, { once: true })
|
|
634
|
-
try {
|
|
635
|
-
$frame.contentWindow.location.reload()
|
|
636
|
-
} catch {
|
|
637
|
-
$frame.src = app.url
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
function switchToApp(index) {
|
|
642
|
-
const app = cfg.apps[index]
|
|
643
|
-
if (!app) return
|
|
644
|
-
|
|
645
|
-
hideIdleHint()
|
|
646
|
-
|
|
647
|
-
// The night-mode and fullscreen buttons are .dock-items too; only app
|
|
648
|
-
// items carry a data-index.
|
|
649
|
-
document.querySelectorAll('.dock-item').forEach((el) => {
|
|
650
|
-
el.classList.toggle('active', el.dataset.index === String(index))
|
|
651
|
-
})
|
|
652
|
-
|
|
653
|
-
const fresh = opensFresh(app)
|
|
654
|
-
const alreadyLoaded = !fresh && !!iframes[app.url]
|
|
655
|
-
|
|
656
|
-
// Tapping the app already on screen reloads it where it is, so a webapp
|
|
657
|
-
// updated on the server is picked up without restarting the browser.
|
|
658
|
-
// destroy mode and fresh apps get a new frame below anyway.
|
|
659
|
-
if (alreadyLoaded && cfg.iframeMode !== 'destroy' && iframes[app.url].classList.contains('active')) {
|
|
660
|
-
reloadFrame(iframes[app.url], app)
|
|
661
|
-
updateHandle()
|
|
662
|
-
hideDock()
|
|
663
|
-
return
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
if (cfg.iframeMode === 'destroy') {
|
|
667
|
-
$iframeContainer.innerHTML = ''
|
|
668
|
-
Object.keys(iframes).forEach((k) => delete iframes[k])
|
|
669
|
-
} else {
|
|
670
|
-
Object.values(iframes).forEach((f) => f.classList.remove('active'))
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
if (fresh && iframes[app.url]) {
|
|
674
|
-
iframes[app.url].remove()
|
|
675
|
-
delete iframes[app.url]
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
if (!iframes[app.url]) {
|
|
679
|
-
if (!alreadyLoaded) showLoading(app.label)
|
|
680
|
-
|
|
681
|
-
const $frame = document.createElement('iframe')
|
|
682
|
-
$frame.allow = 'fullscreen; geolocation'
|
|
683
|
-
$frame.sandbox =
|
|
684
|
-
'allow-same-origin allow-scripts allow-forms allow-popups allow-pointer-lock allow-top-navigation-by-user-activation'
|
|
685
|
-
$frame.style.touchAction = 'auto'
|
|
686
|
-
|
|
687
|
-
$frame.addEventListener('load', hideLoading, { once: true })
|
|
688
|
-
|
|
689
|
-
$iframeContainer.appendChild($frame)
|
|
690
|
-
$frame.src = app.url
|
|
691
|
-
|
|
692
|
-
iframes[app.url] = $frame
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
iframes[app.url].classList.add('active')
|
|
696
|
-
updateHandle()
|
|
697
|
-
hideDock()
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// ─── Gesture: full-screen double-tap ────────────────────────────────────────
|
|
701
|
-
// No overlay element. We attach passive, non-preventDefault listeners to the
|
|
702
|
-
// parent document AND each same-origin iframe's contentDocument (recursively,
|
|
703
|
-
// to handle webapps like KIP that embed other apps), so webapps receive every
|
|
704
|
-
// tap/click normally. A double-tap anywhere opens the dock. Side effect:
|
|
705
|
-
// double-tapping a button fires that button once before the dock opens —
|
|
706
|
-
// users are expected to aim double-taps at non-interactive regions.
|
|
707
|
-
const DOUBLE_TAP_MS = 450
|
|
708
|
-
const DOUBLE_TAP_SLOP = 40
|
|
709
|
-
|
|
710
|
-
let lastTapTime = 0
|
|
711
|
-
let lastTapX = 0
|
|
712
|
-
let lastTapY = 0
|
|
713
|
-
// Coordinates are in the dock's viewport unless a tap names another space:
|
|
714
|
-
// two taps pair up only within the same one.
|
|
715
|
-
let lastTapSpace = null
|
|
716
|
-
|
|
717
|
-
// Taps are timed by when they happened, not when their handler ran: a page
|
|
718
|
-
// busy rendering can hold a pointerdown back long enough to split a
|
|
719
|
-
// double-tap. Times are on the clock every document in the browser shares,
|
|
720
|
-
// performance.timeOrigin plus the page's own high-resolution time.
|
|
721
|
-
function nowTime() {
|
|
722
|
-
return performance.timeOrigin + performance.now()
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
function eventTime(doc, e) {
|
|
726
|
-
const perf = doc.defaultView && doc.defaultView.performance
|
|
727
|
-
return perf ? perf.timeOrigin + e.timeStamp : nowTime()
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
function triggerDock() {
|
|
731
|
-
if (DEBUG) console.log('[Dock] triggerDock called, dockVisible=', dockVisible)
|
|
732
|
-
if (dockVisible) return
|
|
733
|
-
showDock()
|
|
734
|
-
if (DEBUG) {
|
|
735
|
-
const r = $dock.getBoundingClientRect()
|
|
736
|
-
console.log('[Dock] after showDock: dock rect', r, 'classes', $dock.className)
|
|
737
|
-
}
|
|
738
|
-
if (navigator.vibrate) navigator.vibrate(8)
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
const DEBUG = new URLSearchParams(location.search).has('debugDock')
|
|
742
|
-
|
|
743
|
-
function handleTap(clientX, clientY, space = null, time = nowTime()) {
|
|
744
|
-
if (dockVisible) return
|
|
745
|
-
const dx = clientX - lastTapX
|
|
746
|
-
const dy = clientY - lastTapY
|
|
747
|
-
const dist = Math.round(Math.sqrt(dx * dx + dy * dy))
|
|
748
|
-
const close = dist <= DOUBLE_TAP_SLOP && space === lastTapSpace
|
|
749
|
-
const gap = time - lastTapTime
|
|
750
|
-
const match = gap >= 0 && gap < DOUBLE_TAP_MS && close
|
|
751
|
-
if (DEBUG) {
|
|
752
|
-
console.log('[Dock] tap', { x: Math.round(clientX), y: Math.round(clientY), gap, dist, close, match })
|
|
753
|
-
}
|
|
754
|
-
if (match) {
|
|
755
|
-
lastTapTime = 0
|
|
756
|
-
triggerDock()
|
|
757
|
-
} else {
|
|
758
|
-
lastTapTime = time
|
|
759
|
-
lastTapX = clientX
|
|
760
|
-
lastTapY = clientY
|
|
761
|
-
lastTapSpace = space
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
function attachTapListener(doc, frameEl) {
|
|
766
|
-
if (!doc || doc.__dockListenersAttached) return
|
|
767
|
-
doc.__dockListenersAttached = true
|
|
768
|
-
|
|
769
|
-
const offset = () => {
|
|
770
|
-
if (!frameEl) return { x: 0, y: 0 }
|
|
771
|
-
const r = frameEl.getBoundingClientRect()
|
|
772
|
-
return { x: r.left, y: r.top }
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// Dedupe: touchstart and pointerdown often both fire for the same gesture.
|
|
776
|
-
// We accept whichever arrives first and ignore the other if it's within
|
|
777
|
-
// 50 ms (the browser's synthesized-event coalescence window). The window is
|
|
778
|
-
// measured in event time: after the page stalls, two real taps can be
|
|
779
|
-
// handled back to back, and neither may be taken for the other's twin.
|
|
780
|
-
let lastEventAt = -Infinity
|
|
781
|
-
const COALESCE_MS = 50
|
|
782
|
-
|
|
783
|
-
const onDown = (x, y, time) => {
|
|
784
|
-
if (Math.abs(time - lastEventAt) < COALESCE_MS) return
|
|
785
|
-
lastEventAt = time
|
|
786
|
-
handleTap(x, y, null, time)
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
doc.addEventListener(
|
|
790
|
-
'pointerdown',
|
|
791
|
-
(e) => {
|
|
792
|
-
const o = offset()
|
|
793
|
-
onDown(e.clientX + o.x, e.clientY + o.y, eventTime(doc, e))
|
|
794
|
-
},
|
|
795
|
-
{ passive: true, capture: true }
|
|
796
|
-
)
|
|
797
|
-
|
|
798
|
-
// Fallback for Windows touchscreens + some browsers where the root
|
|
799
|
-
// document does not receive pointerdown for finger input (observed on
|
|
800
|
-
// W11 with touch-action: none). touchstart is the universally-supported
|
|
801
|
-
// event for direct touch.
|
|
802
|
-
doc.addEventListener(
|
|
803
|
-
'touchstart',
|
|
804
|
-
(e) => {
|
|
805
|
-
if (!e.touches || e.touches.length === 0) return
|
|
806
|
-
const t = e.touches[0]
|
|
807
|
-
const o = offset()
|
|
808
|
-
onDown(t.clientX + o.x, t.clientY + o.y, eventTime(doc, e))
|
|
809
|
-
},
|
|
810
|
-
{ passive: true, capture: true }
|
|
811
|
-
)
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// Recursively attach to iframes at any nesting depth. Webapps like KIP
|
|
815
|
-
// embed other webapps (e.g. FSK) in their own iframes, so we walk the tree
|
|
816
|
-
// and observe each same-origin contentDocument for future iframe additions.
|
|
817
|
-
const attachedDocs = new WeakSet()
|
|
818
|
-
|
|
819
|
-
function attachToIframe(frame) {
|
|
820
|
-
const wire = () => {
|
|
821
|
-
let doc = null
|
|
822
|
-
try {
|
|
823
|
-
doc = frame.contentDocument
|
|
824
|
-
} catch {
|
|
825
|
-
// cross-origin; can't reach
|
|
826
|
-
}
|
|
827
|
-
const app = appFrameOf(frame)
|
|
828
|
-
if (app) noteFrameReach(app, frame, doc !== null)
|
|
829
|
-
if (!doc || attachedDocs.has(doc)) return
|
|
830
|
-
attachedDocs.add(doc)
|
|
831
|
-
attachTapListener(doc, frame)
|
|
832
|
-
scanAndObserveDoc(doc)
|
|
833
|
-
}
|
|
834
|
-
frame.addEventListener('load', wire)
|
|
835
|
-
wire()
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
function scanAndObserveDoc(doc) {
|
|
839
|
-
// Attach to iframes already in this document.
|
|
840
|
-
doc.querySelectorAll('iframe').forEach((f) => attachToIframe(f))
|
|
841
|
-
|
|
842
|
-
// Observe future iframe additions anywhere in the doc tree.
|
|
843
|
-
const root = doc.documentElement || doc.body || doc
|
|
844
|
-
const obs = new MutationObserver((records) => {
|
|
845
|
-
for (const r of records) {
|
|
846
|
-
r.addedNodes.forEach((n) => {
|
|
847
|
-
if (!n || n.nodeType !== 1) return
|
|
848
|
-
if (n.tagName === 'IFRAME') attachToIframe(n)
|
|
849
|
-
// Descendants: catch iframes added as part of a subtree.
|
|
850
|
-
const nested = n.querySelectorAll && n.querySelectorAll('iframe')
|
|
851
|
-
if (nested) nested.forEach((f) => attachToIframe(f))
|
|
852
|
-
})
|
|
853
|
-
// A removed frame may have been the one keeping the handle up.
|
|
854
|
-
if (r.removedNodes.length > 0) updateHandle()
|
|
855
|
-
}
|
|
856
|
-
})
|
|
857
|
-
obs.observe(root, { childList: true, subtree: true })
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// ─── Apps on another origin ──────────────────────────────────────────────────
|
|
861
|
-
// The listeners above cannot reach a page from another origin, so a
|
|
862
|
-
// double-tap over one goes unseen. That page may be the app itself or a
|
|
863
|
-
// frame inside it. Such a page can forward its pointerdowns instead, and
|
|
864
|
-
// while any page in the active app is out of reach and not forwarding, a
|
|
865
|
-
// tab at the dock's edge opens the dock with a single tap.
|
|
866
|
-
//
|
|
867
|
-
// A forwarded pointerdown is a message posted to the dock's window:
|
|
868
|
-
// window.top.postMessage({ type: 'signalk-app-dock:pointerdown', x: e.clientX, y: e.clientY }, '*')
|
|
869
|
-
// It counts only from inside a frame of the active app that the listeners
|
|
870
|
-
// cannot reach, so no tap is counted twice, and all it can do is feed the
|
|
871
|
-
// double-tap detection.
|
|
872
|
-
const FORWARDED_POINTERDOWN = 'signalk-app-dock:pointerdown'
|
|
873
|
-
const FORWARD_COALESCE_MS = 50
|
|
874
|
-
// Per app frame, the frames in it (itself included) the listeners cannot
|
|
875
|
-
// reach, and which of those have sent a pointerdown of their own since they
|
|
876
|
-
// loaded.
|
|
877
|
-
const unreachableFrames = new WeakMap()
|
|
878
|
-
const forwardingFrames = new WeakSet()
|
|
879
|
-
let lastForwardedAt = -Infinity
|
|
880
|
-
|
|
881
|
-
// The app frame a frame belongs to: the frame itself, or the one holding
|
|
882
|
-
// the document it sits in, found through same-origin parents.
|
|
883
|
-
function appFrameOf(frame) {
|
|
884
|
-
let f = frame
|
|
885
|
-
while (f && f.parentElement !== $iframeContainer) {
|
|
886
|
-
const win = f.ownerDocument && f.ownerDocument.defaultView
|
|
887
|
-
f = win ? win.frameElement : null
|
|
888
|
-
}
|
|
889
|
-
return f
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
// Runs on every load of a frame in an app. A new document starts over:
|
|
893
|
-
// whether it forwards is not known until it does, and an app frame's new
|
|
894
|
-
// document brings its own frames.
|
|
895
|
-
function noteFrameReach(app, frame, reachable) {
|
|
896
|
-
forwardingFrames.delete(frame)
|
|
897
|
-
let frames = unreachableFrames.get(app)
|
|
898
|
-
if (frame === app || !frames) {
|
|
899
|
-
frames = new Set()
|
|
900
|
-
unreachableFrames.set(app, frames)
|
|
901
|
-
}
|
|
902
|
-
if (reachable) frames.delete(frame)
|
|
903
|
-
else frames.add(frame)
|
|
904
|
-
updateHandle()
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// Frames removed from their document, or left in one that has been
|
|
908
|
-
// navigated away, are dropped as they are found.
|
|
909
|
-
function liveUnreachableFrames(app) {
|
|
910
|
-
const frames = (app && unreachableFrames.get(app)) || new Set()
|
|
911
|
-
for (const f of frames) {
|
|
912
|
-
if (!f.isConnected || !f.ownerDocument.defaultView) frames.delete(f)
|
|
913
|
-
}
|
|
914
|
-
return frames
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
function updateHandle() {
|
|
918
|
-
const app = $iframeContainer.querySelector('iframe.active')
|
|
919
|
-
let show = false
|
|
920
|
-
if (!dockVisible) {
|
|
921
|
-
for (const f of liveUnreachableFrames(app)) {
|
|
922
|
-
if (!forwardingFrames.has(f)) show = true
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
$dockHandle.classList.toggle('visible', show)
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
// Where a frame's content starts in the dock's viewport, adding up the
|
|
929
|
-
// frames it sits in.
|
|
930
|
-
function offsetInDock(frame) {
|
|
931
|
-
let x = 0
|
|
932
|
-
let y = 0
|
|
933
|
-
for (let f = frame; f; ) {
|
|
934
|
-
const r = f.getBoundingClientRect()
|
|
935
|
-
x += r.left
|
|
936
|
-
y += r.top
|
|
937
|
-
if (f.parentElement === $iframeContainer) break
|
|
938
|
-
const win = f.ownerDocument.defaultView
|
|
939
|
-
f = win ? win.frameElement : null
|
|
940
|
-
}
|
|
941
|
-
return { x, y }
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
function isInsideFrame(source, frameWindow) {
|
|
945
|
-
for (let w = source; w; w = w.parent) {
|
|
946
|
-
if (w === frameWindow) return true
|
|
947
|
-
if (w === w.parent) return false
|
|
948
|
-
}
|
|
949
|
-
return false
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
window.addEventListener('message', (e) => {
|
|
953
|
-
const data = e.data
|
|
954
|
-
if (!data || data.type !== FORWARDED_POINTERDOWN) return
|
|
955
|
-
if (!Number.isFinite(data.x) || !Number.isFinite(data.y)) return
|
|
956
|
-
const app = $iframeContainer.querySelector('iframe.active')
|
|
957
|
-
if (!app || !e.source) return
|
|
958
|
-
let from = null
|
|
959
|
-
for (const f of liveUnreachableFrames(app)) {
|
|
960
|
-
if (isInsideFrame(e.source, f.contentWindow)) from = f
|
|
961
|
-
}
|
|
962
|
-
if (!from) return
|
|
963
|
-
// A frame inside the page forwarding says nothing about taps on the page
|
|
964
|
-
// around it, so only the page's own message stops the handle.
|
|
965
|
-
if (e.source === from.contentWindow) {
|
|
966
|
-
forwardingFrames.add(from)
|
|
967
|
-
updateHandle()
|
|
968
|
-
}
|
|
969
|
-
// The page can say when the tap happened. Without that, or with a time
|
|
970
|
-
// that has not come yet, the message's arrival stands in for it.
|
|
971
|
-
const received = nowTime()
|
|
972
|
-
const time = Number.isFinite(data.t) && data.t <= received ? data.t : received
|
|
973
|
-
if (Math.abs(time - lastForwardedAt) < FORWARD_COALESCE_MS) return
|
|
974
|
-
lastForwardedAt = time
|
|
975
|
-
// A frame inside the page sits somewhere the dock cannot see, so its
|
|
976
|
-
// taps keep their own coordinates and pair up only with each other.
|
|
977
|
-
if (e.source === from.contentWindow) {
|
|
978
|
-
const o = offsetInDock(from)
|
|
979
|
-
handleTap(data.x + o.x, data.y + o.y, null, time)
|
|
980
|
-
} else {
|
|
981
|
-
handleTap(data.x, data.y, e.source, time)
|
|
982
|
-
}
|
|
983
|
-
})
|
|
984
|
-
|
|
985
|
-
$dockHandle.addEventListener('click', triggerDock)
|
|
986
|
-
|
|
987
|
-
attachTapListener(document, null)
|
|
988
|
-
attachedDocs.add(document)
|
|
989
|
-
scanAndObserveDoc(document)
|
|
990
|
-
|
|
991
|
-
// ─── Init ────────────────────────────────────────────────────────────────────
|
|
992
|
-
buildDock()
|
|
993
|
-
|
|
994
|
-
if (cfg.showNightModeButton && nightModeUsable) {
|
|
995
|
-
fetchCurrentMode()
|
|
996
|
-
setInterval(fetchCurrentMode, 5000)
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
if (cfg.showFullscreenButton && fullscreenSupported()) {
|
|
1000
|
-
document.addEventListener('fullscreenchange', updateFullscreenIcon)
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
// Changes to any of these require a full rebuild (dock classes, magnification
|
|
1004
|
-
// constants, dock items, etc.) — easiest to just reload the page.
|
|
1005
|
-
const STRUCTURAL_KEYS = [
|
|
1006
|
-
'position',
|
|
1007
|
-
'iframeMode',
|
|
1008
|
-
'iconSize',
|
|
1009
|
-
'magnification',
|
|
1010
|
-
'magnificationScale',
|
|
1011
|
-
'showNightModeButton',
|
|
1012
|
-
'showFullscreenButton',
|
|
1013
|
-
'showExitButton'
|
|
1014
|
-
]
|
|
1015
|
-
|
|
1016
|
-
async function refreshConfig() {
|
|
1017
|
-
try {
|
|
1018
|
-
const prev = cfg
|
|
1019
|
-
let next = cfg
|
|
1020
|
-
if (noAdminAccess) {
|
|
1021
|
-
next = (await fetchSharedConfig()) || prev
|
|
1022
|
-
} else {
|
|
1023
|
-
const [configRes, settingsRes] = await Promise.all([
|
|
1024
|
-
fetch('/plugins/signalk-app-dock/config'),
|
|
1025
|
-
fetch('/plugins/signalk-app-dock/settings')
|
|
1026
|
-
])
|
|
1027
|
-
if (configRes.ok) {
|
|
1028
|
-
const data = await configRes.json()
|
|
1029
|
-
const pluginCfg = data.configuration || data
|
|
1030
|
-
next = { ...DEFAULTS, ...pluginCfg, apps: prev.apps }
|
|
1031
|
-
}
|
|
1032
|
-
if (settingsRes.ok) {
|
|
1033
|
-
const data = await settingsRes.json()
|
|
1034
|
-
if (Array.isArray(data.apps) && data.apps.length > 0) {
|
|
1035
|
-
next = { ...next, apps: data.apps }
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
const structuralChanged = STRUCTURAL_KEYS.some((k) => prev[k] !== next[k])
|
|
1040
|
-
if (structuralChanged) {
|
|
1041
|
-
window.location.reload()
|
|
1042
|
-
return
|
|
1043
|
-
}
|
|
1044
|
-
if (JSON.stringify(next.apps) !== JSON.stringify(prev.apps)) {
|
|
1045
|
-
cfg = next
|
|
1046
|
-
buildDock()
|
|
1047
|
-
} else {
|
|
1048
|
-
cfg = next
|
|
1049
|
-
}
|
|
1050
|
-
} catch {
|
|
1051
|
-
// ignore transient errors (e.g. during plugin restart)
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
if (pluginReachable) setInterval(refreshConfig, 5000)
|
|
1056
|
-
|
|
1057
|
-
// ─── Welcome tour ────────────────────────────────────────────────────────────
|
|
1058
|
-
const $tourOverlay = document.getElementById('tour-overlay')
|
|
1059
|
-
const $tourGotIt = document.getElementById('tour-btn-got-it')
|
|
1060
|
-
const $tourDismiss = document.getElementById('tour-btn-dismiss')
|
|
1061
|
-
|
|
1062
|
-
function hideTour() {
|
|
1063
|
-
$tourOverlay.classList.remove('visible')
|
|
1064
|
-
hideDock()
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
function showTour() {
|
|
1068
|
-
$tourOverlay.classList.add('visible')
|
|
1069
|
-
showDock()
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
if ($tourGotIt) $tourGotIt.addEventListener('click', hideTour)
|
|
1073
|
-
|
|
1074
|
-
// "Don't show again" POSTs /dismiss-tour, which saves tourDismissed in the
|
|
1075
|
-
// plugin configuration, so it applies to every user. The plugin opens that
|
|
1076
|
-
// route to readwrite users. Everyone else who is not an admin, and anyone
|
|
1077
|
-
// /settings did not serve the dock to, gets no button rather than one that
|
|
1078
|
-
// advertises a setting we can't honour. The tour reappears on their next
|
|
1079
|
-
// visit.
|
|
1080
|
-
if (!pluginReachable || !canWrite) {
|
|
1081
|
-
if ($tourDismiss) $tourDismiss.remove()
|
|
1082
|
-
document.getElementById('tour-buttons')?.style.setProperty('justify-content', 'center')
|
|
1083
|
-
} else if ($tourDismiss) {
|
|
1084
|
-
$tourDismiss.addEventListener('click', async () => {
|
|
1085
|
-
hideTour()
|
|
1086
|
-
try {
|
|
1087
|
-
await fetch('/plugins/signalk-app-dock/dismiss-tour', { method: 'POST' })
|
|
1088
|
-
} catch {
|
|
1089
|
-
// ignore network errors; worst case user sees the tour again next reload
|
|
1090
|
-
}
|
|
1091
|
-
})
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
const $tourLink = document.getElementById('tour-link')
|
|
1095
|
-
if ($tourLink) {
|
|
1096
|
-
if (noAdminAccess) {
|
|
1097
|
-
// Non-admin visitors can't reach Plugin Config; replace the link with
|
|
1098
|
-
// plain text so the tour doesn't advertise a dead-end.
|
|
1099
|
-
$tourLink.replaceWith(document.createTextNode('Plugin Config (admin only)'))
|
|
1100
|
-
} else {
|
|
1101
|
-
$tourLink.addEventListener('click', (e) => {
|
|
1102
|
-
e.preventDefault()
|
|
1103
|
-
const adminUrl = '/admin/#/serverConfiguration/plugins/signalk-app-dock'
|
|
1104
|
-
hideIdleHint()
|
|
1105
|
-
const $frame = document.createElement('iframe')
|
|
1106
|
-
$frame.allow = 'fullscreen; geolocation'
|
|
1107
|
-
$frame.sandbox =
|
|
1108
|
-
'allow-same-origin allow-scripts allow-forms allow-popups allow-pointer-lock allow-top-navigation-by-user-activation'
|
|
1109
|
-
$frame.style.touchAction = 'auto'
|
|
1110
|
-
$frame.classList.add('active')
|
|
1111
|
-
$iframeContainer.innerHTML = ''
|
|
1112
|
-
Object.keys(iframes).forEach((k) => delete iframes[k])
|
|
1113
|
-
$iframeContainer.appendChild($frame)
|
|
1114
|
-
$frame.src = adminUrl
|
|
1115
|
-
iframes[adminUrl] = $frame
|
|
1116
|
-
hideTour()
|
|
1117
|
-
})
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
if (!cfg.tourDismissed) showTour()
|
|
1122
|
-
|
|
1123
|
-
const autostartIdx = cfg.apps.findIndex((a) => a.autostart)
|
|
1124
|
-
if (autostartIdx >= 0) {
|
|
1125
|
-
switchToApp(autostartIdx)
|
|
1126
|
-
} else {
|
|
1127
|
-
setIdleHint()
|
|
1128
|
-
}
|
|
1129
|
-
})()
|
|
1
|
+
function e(e){let t=document.getElementById(e);if(!t)throw Error(`[Dock] #${e} is missing from index.html`);return t}function t(e){return typeof e==`number`&&Number.isFinite(e)}(async function(){if(window.self!==window.top){let e=window.location.href;try{window.top?.location.replace(e)}catch{}document.body.innerHTML=`<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;height:100vh;color:#fff;background:#000;font-family:-apple-system,BlinkMacSystemFont,sans-serif;text-align:center;padding:16px"><img src="app-icon.svg" alt="App Dock" style="width:72px;height:72px;border-radius:16px;opacity:0.85" /><a href="`+e+`" target="_top" style="color:#fff;text-decoration:underline;font-size:16px">Open App Dock</a></div>`;return}let n={position:`bottom`,iframeMode:`keep-alive`,iconSize:56,magnification:!0,magnificationScale:1.7,showNightModeButton:!0,showFullscreenButton:!1,showExitButton:!1,apps:[]},r={showNightModeButton:!0,apps:[{enabled:!0,autostart:!0,url:`/@signalk/freeboard-sk/`,label:`Freeboard-SK`,icon:`/@signalk/freeboard-sk/assets/icons/icon-72x72.png`,color:``},{enabled:!0,autostart:!1,url:`/@halos-org/skip/`,label:`Skip`,icon:`/@halos-org/skip/assets/icon-72x72.png`,color:``},{enabled:!0,autostart:!1,url:`/admin/`,label:`Settings`,icon:`/@signalk/app-dock/icon-settings.svg`,color:`#78788c`}]},i=!1,a=!0;for(let e of[`/skServer/loginStatus`,`/loginStatus`])try{let t=await fetch(e);if(t.ok){let e=await t.json(),n=e.authenticationRequired===!1,r=e.status===`loggedIn`?e.userLevel:void 0;i=!n&&r!==`admin`,a=n||r===`admin`||r===`readwrite`;break}}catch{}async function o(){try{let e=await fetch(`/plugins/signalk-app-dock/settings`);if(!e.ok)return null;let t=await e.json();return Array.isArray(t.apps)?{...n,...t}:null}catch{return null}}let s={...n},c=!i;if(i){let e=await o();c=e!==null,s=e||{...n,...r}}else try{let[e,t]=await Promise.all([fetch(`/plugins/signalk-app-dock/config`),fetch(`/plugins/signalk-app-dock/settings`)]);if(e.ok){let t=await e.json(),r=t.configuration||t;s={...n,...r}}if(t.ok){let e=await t.json();Array.isArray(e.apps)&&e.apps.length>0&&(s.apps=e.apps)}}catch(e){console.warn(`[Dock] Could not load config, using defaults.`,e)}(!Array.isArray(s.apps)||s.apps.length===0)&&console.warn(`[Dock] No apps configured — open Plugin Config to add webapps.`);let l=e(`iframe-container`),u=e(`dock-backdrop`),d=e(`dock`),f=e(`dock-inner`),p=e(`loading-overlay`),ee=e(`loading-label`),te=document.getElementById(`idle-hint`),m=e(`idle-hint-text`),h=e(`dock-handle`),g=!1,_={},v=`day`,y=c&&a;async function b(){if(c)try{let e=await fetch(`/plugins/signalk-app-dock/mode`);e.ok&&(v=(await e.json()).value||`day`,x())}catch(e){console.warn(`[Dock] Could not fetch environment.mode`,e)}}function x(){let e=document.querySelector(`.dock-item-nightmode .dock-icon`);if(!e)return;e.textContent=v===`night`?`🌙`:`☀️`;let t=document.querySelector(`.dock-item-nightmode .dock-label`);t&&(t.textContent=v===`night`?`Day mode`:`Night mode`)}async function ne(){if(!y)return;await b();let e=v===`night`?`day`:`night`;try{(await fetch(`/signalk/v1/api/vessels/self/environment/mode`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify({value:e})})).ok&&(v=e,x())}catch(e){console.warn(`[Dock] Failed to toggle mode`,e)}}function S(){return!!(document.fullscreenEnabled&&document.documentElement.requestFullscreen)}function C(){return!!document.fullscreenElement}function re(){S()&&(C()?document.exitFullscreen().catch(e=>console.warn(`[Dock] exitFullscreen`,e)):document.documentElement.requestFullscreen().catch(e=>console.warn(`[Dock] requestFullscreen`,e)))}function ie(){let e=document.querySelector(`.dock-item-fullscreen .dock-icon`);if(!e)return;e.textContent=C()?`⤢`:`⛶`;let t=document.querySelector(`.dock-item-fullscreen .dock-label`);t&&(t.textContent=C()?`Exit fullscreen`:`Fullscreen`)}let w=s.position;d.classList.add(`pos-${w}`),f.classList.add(`align-${w}`),h.classList.add(`pos-${w}`);let T=w===`left`||w===`right`;T&&f.classList.add(`layout-vertical`);let ae=s.magnification!==!1,oe=Math.max(1,Math.min(s.magnificationScale||1.7,2.5)),se=s.iconSize*3.5;function ce(){let e=document.getElementById(`idle-version`);e&&fetch(`/skServer/webapps`).then(e=>e.json()).then(t=>{let n=t.find(e=>e.name===`@signalk/app-dock`);n&&(e.textContent=`v`+n.version)}).catch(()=>{}),!s.apps||s.apps.length===0?i?m.innerHTML=`App Dock can only be customised by an administrator.<br>Ask an admin to set up apps in <strong>Plugin Config</strong>.`:m.innerHTML=`No apps configured yet.<br>Open <strong>Admin UI → Plugin Config → App Dock</strong><br>and click <strong>Discover Installed Webapps</strong> to get started.`:m.innerHTML=`Double-tap <strong>anywhere</strong> to open the dock`+(i?`<br><br><span style="font-size:12px;opacity:0.6">Customisation is admin-only</span>`:`<br><br><span style="font-size:12px;opacity:0.6">Configure in Admin UI → Plugin Config → App Dock</span>`)}function E(){te&&te.classList.add(`hidden`)}function le(){switch(w){case`top`:return`dock-label-below`;case`left`:return`dock-label-right`;case`right`:return`dock-label-left`;default:return``}}function D(e,t,n,r){let i=s.iconSize,a=Math.round(i*.25)+`px`,o=le(),c=document.createElement(`div`);c.className=`dock-item dock-item-utility `+e;let l=document.createElement(`div`);l.className=`dock-icon dock-icon-utility`,l.style.width=i+`px`,l.style.height=i+`px`,l.style.borderRadius=a,l.style.fontSize=Math.round(i*.48)+`px`,l.textContent=t;let u=document.createElement(`div`);u.className=`dock-dot`;let d=document.createElement(`div`);return d.className=`dock-label`+(o?` `+o:``),d.textContent=n,c.appendChild(l),c.appendChild(u),c.appendChild(d),c.addEventListener(`touchstart`,()=>c.classList.add(`pressing`),{passive:!0}),c.addEventListener(`touchend`,()=>c.classList.remove(`pressing`),{passive:!0}),c.addEventListener(`touchcancel`,()=>c.classList.remove(`pressing`),{passive:!0}),c.addEventListener(`click`,e=>{A?e.preventDefault():r()}),c}function ue(){let e=document.createElement(`div`);return e.className=`dock-separator`,e}function O(){f.innerHTML=``;let e=s.iconSize,t=Math.round(e*.25)+`px`,n=le(),r=s.showNightModeButton&&y;r&&f.appendChild(D(`dock-item-nightmode`,`☀️`,`Night mode`,()=>void ne())),s.showFullscreenButton&&S()&&f.appendChild(D(`dock-item-fullscreen`,`⛶`,`Fullscreen`,re)),(r||s.showFullscreenButton&&S())&&f.appendChild(ue()),s.apps.forEach((r,i)=>{let a=document.createElement(`div`);a.className=`dock-item`,a.dataset.index=String(i);let o=document.createElement(`div`);o.className=`dock-icon`,o.style.width=e+`px`,o.style.height=e+`px`,o.style.borderRadius=t,r.color&&(o.style.background=r.color);let s=r.icon||``;if(s.startsWith(`/`)||s.startsWith(`http`)){let t=document.createElement(`img`);t.src=s,t.alt=r.label,t.onerror=()=>{o.removeChild(t),o.style.fontSize=Math.round(e*.42)+`px`,o.textContent=r.label.slice(0,2).toUpperCase()},o.appendChild(t)}else o.style.fontSize=Math.round(e*.48)+`px`,o.textContent=s||r.label.slice(0,2).toUpperCase();let c=document.createElement(`div`);c.className=`dock-dot`;let l=document.createElement(`div`);l.className=`dock-label`+(n?` `+n:``),l.textContent=r.label,a.appendChild(o),a.appendChild(c),a.appendChild(l),a.addEventListener(`touchstart`,()=>a.classList.add(`pressing`),{passive:!0}),a.addEventListener(`touchend`,()=>a.classList.remove(`pressing`),{passive:!0}),a.addEventListener(`touchcancel`,()=>a.classList.remove(`pressing`),{passive:!0}),a.addEventListener(`click`,e=>{A?e.preventDefault():ve(i)}),f.appendChild(a)}),s.showExitButton&&(f.appendChild(ue()),f.appendChild(D(`dock-item-exit`,`✕`,`Exit to Admin`,()=>{window.location.href=`/admin/`}))),s.showNightModeButton&&x(),s.showFullscreenButton&&S()&&ie()}let k=!1,A=!1,j=0;function M(e){let t=f.querySelectorAll(`.dock-item`),n=-1,r=1/0;t.forEach((t,i)=>{let a=t.querySelector(`.dock-icon`);if(!a)return;let o=t.getBoundingClientRect(),c=T?o.top+o.height/2:o.left+o.width/2,l=T?e.y:e.x,u=Math.abs(l-c);u<r&&(r=u,n=i);let d=Math.min(u/se,1),f=1+(oe-1)*(1-d*d),p=Math.round(s.iconSize*f);a.classList.remove(`settling`),a.style.width=p+`px`,a.style.height=p+`px`,a.style.borderRadius=Math.round(p*.25)+`px`,a.querySelector(`img`)||(a.style.fontSize=Math.round(p*.48)+`px`)}),t.forEach((e,t)=>{e.classList.toggle(`mag-closest`,t===n)})}function N(){let e=f.querySelectorAll(`.dock-item`),t=s.iconSize;e.forEach(e=>{let n=e.querySelector(`.dock-icon`);n&&(n.classList.add(`settling`),n.style.width=t+`px`,n.style.height=t+`px`,n.style.borderRadius=Math.round(t*.25)+`px`,n.querySelector(`img`)||(n.style.fontSize=Math.round(t*.48)+`px`),e.classList.remove(`mag-closest`))})}ae&&(f.addEventListener(`mousemove`,e=>{if(k)return;k=!0;let t={x:e.clientX,y:e.clientY};requestAnimationFrame(()=>{M(t),k=!1})}),f.addEventListener(`mouseleave`,()=>{k=!1,N()}),f.addEventListener(`touchstart`,e=>{let t=e.touches[0];A=!1,j=T?t.clientY:t.clientX},{passive:!0}),f.addEventListener(`touchmove`,e=>{let t=e.touches[0],n=T?t.clientY:t.clientX;if(Math.abs(n-j)>10&&(A=!0),k)return;k=!0;let r={x:t.clientX,y:t.clientY};requestAnimationFrame(()=>{M(r),k=!1})},{passive:!0}),f.addEventListener(`touchend`,()=>{k=!1,N(),setTimeout(()=>{A=!1},50)},{passive:!0}));let de=0;function fe(){g||(g=!0,de=Date.now(),d.classList.add(`visible`),u.classList.add(`visible`),Y())}function P(){g&&(X&&X.classList.contains(`visible`)||(g=!1,d.classList.remove(`visible`),u.classList.remove(`visible`),N(),Y()))}u.addEventListener(`click`,e=>{Date.now()-de<500?e.stopPropagation():P()});function pe(e){ee.textContent=`Loading ${e}\u2026`,p.classList.add(`visible`)}function me(){p.classList.remove(`visible`)}let he=`allow-same-origin allow-scripts allow-forms allow-popups allow-pointer-lock allow-top-navigation-by-user-activation`;function ge(e){try{let t=new URL(e.url,window.location.href);return t.origin===window.location.origin&&/^\/admin(\/|$)/.test(t.pathname)}catch{return!1}}function _e(e,t){pe(t.label),e.addEventListener(`load`,me,{once:!0});try{e.contentWindow?.location.reload()}catch{e.src=t.url}}function ve(e){let t=s.apps[e];if(!t)return;E(),document.querySelectorAll(`.dock-item`).forEach(t=>{t.classList.toggle(`active`,t.dataset.index===String(e))});let n=ge(t),r=!n&&!!_[t.url];if(r&&s.iframeMode!==`destroy`&&_[t.url].classList.contains(`active`))_e(_[t.url],t),Y(),P();else{if(s.iframeMode===`destroy`?(l.innerHTML=``,Object.keys(_).forEach(e=>delete _[e])):Object.values(_).forEach(e=>e.classList.remove(`active`)),n&&_[t.url]&&(_[t.url].remove(),delete _[t.url]),!_[t.url]){r||pe(t.label);let e=document.createElement(`iframe`);e.allow=`fullscreen; geolocation`,e.setAttribute(`sandbox`,he),e.style.touchAction=`auto`,e.addEventListener(`load`,me,{once:!0}),l.appendChild(e),e.src=t.url,_[t.url]=e}_[t.url].classList.add(`active`),Y(),P()}}let F=0,I=0,L=0,R=null;function z(){return performance.timeOrigin+performance.now()}function B(e,t){let n=e.defaultView&&e.defaultView.performance;return n?n.timeOrigin+t.timeStamp:z()}function V(){if(H&&console.log(`[Dock] triggerDock called, dockVisible=`,g),!g){if(fe(),H){let e=d.getBoundingClientRect();console.log(`[Dock] after showDock: dock rect`,e,`classes`,d.className)}navigator.vibrate&&navigator.vibrate(8)}}let H=new URLSearchParams(location.search).has(`debugDock`);function U(e,t,n=null,r=z()){if(g)return;let i=e-I,a=t-L,o=Math.round(Math.sqrt(i*i+a*a)),s=o<=40&&n===R,c=r-F,l=c>=0&&c<450&&s;H&&console.log(`[Dock] tap`,{x:Math.round(e),y:Math.round(t),gap:c,dist:o,close:s,match:l}),l?(F=0,V()):(F=r,I=e,L=t,R=n)}let ye=new WeakSet;function be(e,t){if(ye.has(e))return;ye.add(e);let n=()=>{if(!t)return{x:0,y:0};let e=t.getBoundingClientRect();return{x:e.left,y:e.top}},r=-1/0,i=(e,t,n)=>{Math.abs(n-r)<50||(r=n,U(e,t,null,n))};e.addEventListener(`pointerdown`,t=>{let r=n();i(t.clientX+r.x,t.clientY+r.y,B(e,t))},{passive:!0,capture:!0}),e.addEventListener(`touchstart`,t=>{if(!t.touches||t.touches.length===0)return;let r=t.touches[0],a=n();i(r.clientX+a.x,r.clientY+a.y,B(e,t))},{passive:!0,capture:!0})}let W=new WeakSet;function G(e){let t=()=>{let t=null;try{t=e.contentDocument}catch{}let n=Ce(e);n&&we(n,e,t!==null),t&&!W.has(t)&&(W.add(t),be(t,e),xe(t))};e.addEventListener(`load`,t),t()}function xe(e){e.querySelectorAll(`iframe`).forEach(e=>G(e));let t=e.documentElement||e.body||e;new MutationObserver(e=>{for(let t of e)t.addedNodes.forEach(e=>{if(!e||e.nodeType!==Node.ELEMENT_NODE)return;let t=e;t.tagName===`IFRAME`&&G(t),t.querySelectorAll(`iframe`).forEach(e=>G(e))}),t.removedNodes.length>0&&Y()}).observe(t,{childList:!0,subtree:!0})}let K=new WeakMap,q=new WeakSet,Se=-1/0;function Ce(e){let t=e;for(;t&&t.parentElement!==l;){let e=t.ownerDocument&&t.ownerDocument.defaultView;t=e?e.frameElement:null}return t}function we(e,t,n){q.delete(t);let r=K.get(e);(t===e||!r)&&(r=new Set,K.set(e,r)),n?r.delete(t):r.add(t),Y()}function J(e){let t=e&&K.get(e)||new Set;for(let e of t)(!e.isConnected||!e.ownerDocument.defaultView)&&t.delete(e);return t}function Y(){let e=l.querySelector(`iframe.active`),t=!1;if(!g)for(let n of J(e))q.has(n)||(t=!0);h.classList.toggle(`visible`,t)}function Te(e){let t=0,n=0;for(let r=e;r;){let e=r.getBoundingClientRect();if(t+=e.left,n+=e.top,r.parentElement===l)break;let i=r.ownerDocument.defaultView;r=i?i.frameElement:null}return{x:t,y:n}}function Ee(e,t){for(let n=e;n;n=n.parent){if(n===t)return!0;if(n===n.parent)return!1}return!1}window.addEventListener(`message`,e=>{let n=e.data;if(!n||n.type!==`signalk-app-dock:pointerdown`||!t(n.x)||!t(n.y))return;let r=l.querySelector(`iframe.active`);if(!r||!e.source)return;let i=null;for(let t of J(r))Ee(e.source,t.contentWindow)&&(i=t);if(!i)return;e.source===i.contentWindow&&(q.add(i),Y());let a=z(),o=t(n.t)&&n.t<=a?n.t:a;if(!(Math.abs(o-Se)<50)){if(Se=o,e.source===i.contentWindow){let e=Te(i);U(n.x+e.x,n.y+e.y,null,o)}else U(n.x,n.y,e.source,o)}}),h.addEventListener(`click`,V),be(document,null),W.add(document),xe(document),O(),s.showNightModeButton&&y&&(b(),setInterval(b,5e3)),s.showFullscreenButton&&S()&&document.addEventListener(`fullscreenchange`,ie);let De=[`position`,`iframeMode`,`iconSize`,`magnification`,`magnificationScale`,`showNightModeButton`,`showFullscreenButton`,`showExitButton`];async function Oe(){try{let e=s,t=s;if(i)t=await o()||e;else{let[r,i]=await Promise.all([fetch(`/plugins/signalk-app-dock/config`),fetch(`/plugins/signalk-app-dock/settings`)]);if(r.ok){let i=await r.json(),a=i.configuration||i;t={...n,...a,apps:e.apps}}if(i.ok){let e=await i.json();Array.isArray(e.apps)&&e.apps.length>0&&(t={...t,apps:e.apps})}}if(De.some(n=>e[n]!==t[n])){window.location.reload();return}JSON.stringify(t.apps)===JSON.stringify(e.apps)?s=t:(s=t,O())}catch{}}c&&setInterval(Oe,5e3);let X=document.getElementById(`tour-overlay`),ke=document.getElementById(`tour-btn-got-it`),Z=document.getElementById(`tour-btn-dismiss`);function Q(){X?.classList.remove(`visible`),P()}function Ae(){X?.classList.add(`visible`),fe()}ke&&ke.addEventListener(`click`,Q),!c||!a?(Z&&Z.remove(),document.getElementById(`tour-buttons`)?.style.setProperty(`justify-content`,`center`)):Z&&Z.addEventListener(`click`,()=>{Q(),fetch(`/plugins/signalk-app-dock/dismiss-tour`,{method:`POST`}).catch(()=>{})});let $=document.getElementById(`tour-link`);$&&(i?$.replaceWith(document.createTextNode(`Plugin Config (admin only)`)):$.addEventListener(`click`,e=>{e.preventDefault();let t=`/admin/#/serverConfiguration/plugins/signalk-app-dock`;E();let n=document.createElement(`iframe`);n.allow=`fullscreen; geolocation`,n.setAttribute(`sandbox`,he),n.style.touchAction=`auto`,n.classList.add(`active`),l.innerHTML=``,Object.keys(_).forEach(e=>delete _[e]),l.appendChild(n),n.src=t,_[t]=n,Q()})),s.tourDismissed||Ae();let je=s.apps.findIndex(e=>e.autostart);je>=0?ve(je):ce()})().catch(e=>console.error(`[Dock] failed to start`,e));
|