@signalk/app-dock 1.4.0 → 1.4.2

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/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
+ var e=(e,t)=>`<svg class="dock-svg-icon dock-svg-${e}" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${t}</svg>`,t={sun:e(`sun`,`<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>`),moon:e(`moon`,`<path d="M20.5 14A9 9 0 1 1 10 3.5a7.5 7.5 0 0 0 10.5 10.5z"/>`),fullscreen:e(`fullscreen`,`<path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/>`),exitFullscreen:e(`exit-fullscreen`,`<path d="M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5"/>`),close:e(`close`,`<path d="M6 6l12 12M18 6L6 18"/>`)};function n(e){let t=document.getElementById(e);if(!t)throw Error(`[Dock] #${e} is missing from index.html`);return t}function r(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 e={position:`bottom`,iframeMode:`keep-alive`,iconSize:56,magnification:!0,magnificationScale:1.7,showNightModeButton:!0,showFullscreenButton:!1,showExitButton:!1,apps:[]},i={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`}]},a=!1,o=!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;a=!n&&r!==`admin`,o=n||r===`admin`||r===`readwrite`;break}}catch{}async function s(){try{let t=await fetch(`/plugins/signalk-app-dock/settings`);if(!t.ok)return null;let n=await t.json();return Array.isArray(n.apps)?{...e,...n}:null}catch{return null}}let c={...e},l=!a;if(a){let t=await s();l=t!==null,c=t||{...e,...i}}else try{let[t,n]=await Promise.all([fetch(`/plugins/signalk-app-dock/config`),fetch(`/plugins/signalk-app-dock/settings`)]);if(t.ok){let n=await t.json(),r=n.configuration||n;c={...e,...r}}if(n.ok){let e=await n.json();Array.isArray(e.apps)&&e.apps.length>0&&(c.apps=e.apps)}}catch(e){console.warn(`[Dock] Could not load config, using defaults.`,e)}(!Array.isArray(c.apps)||c.apps.length===0)&&console.warn(`[Dock] No apps configured — open Plugin Config to add webapps.`);let u=n(`iframe-container`),d=n(`dock-backdrop`),f=n(`dock`),p=n(`dock-inner`),ee=n(`loading-overlay`),te=n(`loading-label`),ne=document.getElementById(`idle-hint`),m=n(`idle-hint-text`),h=n(`dock-handle`),g=!1,_={},v=`day`,y=l&&o;async function b(){if(l)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.innerHTML=t[v===`night`?`moon`:`sun`];let n=document.querySelector(`.dock-item-nightmode .dock-label`);n&&(n.textContent=v===`night`?`Day mode`:`Night mode`)}async function re(){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 ie(){S()&&(C()?document.exitFullscreen().catch(e=>console.warn(`[Dock] exitFullscreen`,e)):document.documentElement.requestFullscreen().catch(e=>console.warn(`[Dock] requestFullscreen`,e)))}function ae(){let e=document.querySelector(`.dock-item-fullscreen .dock-icon`);if(!e)return;e.innerHTML=t[C()?`exitFullscreen`:`fullscreen`];let n=document.querySelector(`.dock-item-fullscreen .dock-label`);n&&(n.textContent=C()?`Exit fullscreen`:`Fullscreen`)}let w=c.position;f.classList.add(`pos-${w}`),p.classList.add(`align-${w}`),h.classList.add(`pos-${w}`);let T=w===`left`||w===`right`;T&&p.classList.add(`layout-vertical`);let oe=c.magnification!==!1,se=Math.max(1,Math.min(c.magnificationScale||1.7,2.5)),ce=c.iconSize*3.5;function le(){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(()=>{}),!c.apps||c.apps.length===0?a?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`+(a?`<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 ue(){ne&&ne.classList.add(`hidden`)}function de(){switch(w){case`top`:return`dock-label-below`;case`left`:return`dock-label-right`;case`right`:return`dock-label-left`;default:return``}}function E(e,n,r,i){let a=c.iconSize,o=Math.round(a*.25)+`px`,s=de(),l=document.createElement(`div`);l.className=`dock-item dock-item-utility `+e;let u=document.createElement(`div`);u.className=`dock-icon dock-icon-utility`,u.style.width=a+`px`,u.style.height=a+`px`,u.style.borderRadius=o,u.innerHTML=t[n];let d=document.createElement(`div`);d.className=`dock-dot`;let f=document.createElement(`div`);return f.className=`dock-label`+(s?` `+s:``),f.textContent=r,l.appendChild(u),l.appendChild(d),l.appendChild(f),l.addEventListener(`touchstart`,()=>l.classList.add(`pressing`),{passive:!0}),l.addEventListener(`touchend`,()=>l.classList.remove(`pressing`),{passive:!0}),l.addEventListener(`touchcancel`,()=>l.classList.remove(`pressing`),{passive:!0}),l.addEventListener(`click`,e=>{O?e.preventDefault():i()}),l}function fe(){let e=document.createElement(`div`);return e.className=`dock-separator`,e}function pe(){p.innerHTML=``;let e=c.iconSize,t=Math.round(e*.25)+`px`,n=de(),r=c.showNightModeButton&&y;r&&p.appendChild(E(`dock-item-nightmode`,`sun`,`Night mode`,()=>void re())),c.showFullscreenButton&&S()&&p.appendChild(E(`dock-item-fullscreen`,`fullscreen`,`Fullscreen`,ie)),(r||c.showFullscreenButton&&S())&&p.appendChild(fe()),c.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=>{O?e.preventDefault():I(i)}),p.appendChild(a)}),c.showExitButton&&(p.appendChild(fe()),p.appendChild(E(`dock-item-exit`,`close`,`Exit to Admin`,()=>{window.location.href=`/admin/`}))),c.showNightModeButton&&x(),c.showFullscreenButton&&S()&&ae()}let D=!1,O=!1,me=0;function he(e){let t=p.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(),s=T?o.top+o.height/2:o.left+o.width/2,l=T?e.y:e.x,u=Math.abs(l-s);u<r&&(r=u,n=i);let d=Math.min(u/ce,1),f=1+(se-1)*(1-d*d),p=Math.round(c.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 k(){let e=p.querySelectorAll(`.dock-item`),t=c.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`))})}oe&&(p.addEventListener(`mousemove`,e=>{if(D)return;D=!0;let t={x:e.clientX,y:e.clientY};requestAnimationFrame(()=>{he(t),D=!1})}),p.addEventListener(`mouseleave`,()=>{D=!1,k()}),p.addEventListener(`touchstart`,e=>{let t=e.touches[0];O=!1,me=T?t.clientY:t.clientX},{passive:!0}),p.addEventListener(`touchmove`,e=>{let t=e.touches[0],n=T?t.clientY:t.clientX;if(Math.abs(n-me)>10&&(O=!0),D)return;D=!0;let r={x:t.clientX,y:t.clientY};requestAnimationFrame(()=>{he(r),D=!1})},{passive:!0}),p.addEventListener(`touchend`,()=>{D=!1,k(),setTimeout(()=>{O=!1},50)},{passive:!0}));let A=0;function j(){g||(g=!0,A=Date.now(),f.classList.add(`visible`),d.classList.add(`visible`),Y())}function M(){g&&(X&&X.classList.contains(`visible`)||(g=!1,f.classList.remove(`visible`),d.classList.remove(`visible`),k(),Y()))}d.addEventListener(`click`,e=>{Date.now()-A<500?e.stopPropagation():M()});function N(e){te.textContent=`Loading ${e}\u2026`,ee.classList.add(`visible`)}function P(){ee.classList.remove(`visible`)}let F=`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){N(t.label),e.addEventListener(`load`,P,{once:!0});try{e.contentWindow?.location.reload()}catch{e.src=t.url}}function I(e){let t=c.apps[e];if(!t)return;ue(),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&&c.iframeMode!==`destroy`&&_[t.url].classList.contains(`active`))_e(_[t.url],t),Y(),M();else{if(c.iframeMode===`destroy`?(u.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||N(t.label);let e=document.createElement(`iframe`);e.allow=`fullscreen; geolocation`,e.setAttribute(`sandbox`,F),e.style.touchAction=`auto`,e.addEventListener(`load`,P,{once:!0}),u.appendChild(e),e.src=t.url,_[t.url]=e}_[t.url].classList.add(`active`),Y(),M()}}let L=0,R=0,z=0,B=null;function V(){return performance.timeOrigin+performance.now()}function ve(e,t){let n=e.defaultView&&e.defaultView.performance;return n?n.timeOrigin+t.timeStamp:V()}function ye(){if(H&&console.log(`[Dock] triggerDock called, dockVisible=`,g),!g){if(j(),H){let e=f.getBoundingClientRect();console.log(`[Dock] after showDock: dock rect`,e,`classes`,f.className)}navigator.vibrate&&navigator.vibrate(8)}}let H=new URLSearchParams(location.search).has(`debugDock`);function U(e,t,n=null,r=V()){if(g)return;let i=e-R,a=t-z,o=Math.round(Math.sqrt(i*i+a*a)),s=o<=40&&n===B,c=r-L,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?(L=0,ye()):(L=r,R=e,z=t,B=n)}let be=new WeakSet;function xe(e,t){if(be.has(e))return;be.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,ve(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,ve(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=we(e);n&&Te(n,e,t!==null),t&&!W.has(t)&&(W.add(t),xe(t,e),Se(t))};e.addEventListener(`load`,t),t()}function Se(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,Ce=-1/0;function we(e){let t=e;for(;t&&t.parentElement!==u;){let e=t.ownerDocument&&t.ownerDocument.defaultView;t=e?e.frameElement:null}return t}function Te(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=u.querySelector(`iframe.active`),t=!1;if(!g)for(let n of J(e))q.has(n)||(t=!0);h.classList.toggle(`visible`,t)}function Ee(e){let t=0,n=0;for(let r=e;r;){let e=r.getBoundingClientRect();if(t+=e.left,n+=e.top,r.parentElement===u)break;let i=r.ownerDocument.defaultView;r=i?i.frameElement:null}return{x:t,y:n}}function De(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 t=e.data;if(!t||t.type!==`signalk-app-dock:pointerdown`||!r(t.x)||!r(t.y))return;let n=u.querySelector(`iframe.active`);if(!n||!e.source)return;let i=null;for(let t of J(n))De(e.source,t.contentWindow)&&(i=t);if(!i)return;e.source===i.contentWindow&&(q.add(i),Y());let a=V(),o=r(t.t)&&t.t<=a?t.t:a;if(!(Math.abs(o-Ce)<50)){if(Ce=o,e.source===i.contentWindow){let e=Ee(i);U(t.x+e.x,t.y+e.y,null,o)}else U(t.x,t.y,e.source,o)}}),h.addEventListener(`click`,ye),xe(document,null),W.add(document),Se(document),pe(),c.showNightModeButton&&y&&(b(),setInterval(b,5e3)),c.showFullscreenButton&&S()&&document.addEventListener(`fullscreenchange`,ae);let Oe=[`position`,`iframeMode`,`iconSize`,`magnification`,`magnificationScale`,`showNightModeButton`,`showFullscreenButton`,`showExitButton`];async function ke(){try{let t=c,n=c;if(a)n=await s()||t;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;n={...e,...a,apps:t.apps}}if(i.ok){let e=await i.json();Array.isArray(e.apps)&&e.apps.length>0&&(n={...n,apps:e.apps})}}if(Oe.some(e=>t[e]!==n[e])){window.location.reload();return}JSON.stringify(n.apps)===JSON.stringify(t.apps)?c=n:(c=n,pe())}catch{}}l&&setInterval(ke,5e3);let X=document.getElementById(`tour-overlay`),Ae=document.getElementById(`tour-btn-got-it`),Z=document.getElementById(`tour-btn-dismiss`);function Q(){X?.classList.remove(`visible`),M()}function je(){X?.classList.add(`visible`),j()}Ae&&Ae.addEventListener(`click`,Q),!l||!o?(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`);$&&(a?$.replaceWith(document.createTextNode(`Plugin Config (admin only)`)):$.addEventListener(`click`,e=>{e.preventDefault();let t=`/admin/#/serverConfiguration/plugins/signalk-app-dock`;ue();let n=document.createElement(`iframe`);n.allow=`fullscreen; geolocation`,n.setAttribute(`sandbox`,F),n.style.touchAction=`auto`,n.classList.add(`active`),u.innerHTML=``,Object.keys(_).forEach(e=>delete _[e]),u.appendChild(n),n.src=t,_[t]=n,Q()})),c.tourDismissed||je();let Me=c.apps.findIndex(e=>e.autostart);Me>=0?I(Me):le()})().catch(e=>console.error(`[Dock] failed to start`,e));