@signalk/app-dock 1.1.0-beta.2 → 1.2.0-beta.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 +26 -9
- package/package.json +1 -1
- package/plugin/index.js +42 -19
- package/public/dock.js +79 -42
- package/public/index.html +1 -1
- package/test/plugin.test.js +155 -1
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ A macOS-style app dock for switching between Signal K webapps on touch screens.
|
|
|
13
13
|
- **Autostart** -- optionally load a default app immediately on open
|
|
14
14
|
- **keep-alive or destroy** iframe lifecycle
|
|
15
15
|
- **Night/day mode toggle** -- optional sun/moon button that flips `environment.mode` via a PUT handler
|
|
16
|
+
- **Fullscreen toggle** -- optional button that hides browser chrome via the Fullscreen API; feature-detected, so hidden on devices without API support (notably iPadOS Safari -- use Add to Home Screen there)
|
|
17
|
+
- **Installable as an app** -- ships a web app manifest and apple-touch-icon so iOS, Android, and desktop browsers can install the dock to the home screen and launch it in standalone mode (no browser chrome). Works over plain HTTP -- no SSL required
|
|
16
18
|
- **Active dot indicator**, label tooltip, haptic feedback
|
|
17
19
|
- **Embedded config panel** in the admin UI with webapp discovery, drag-to-reorder, and live preview
|
|
18
20
|
|
|
@@ -59,15 +61,30 @@ Open **Plugin Config > App Dock** in the admin UI. The embedded configurator pro
|
|
|
59
61
|
|
|
60
62
|
### Settings
|
|
61
63
|
|
|
62
|
-
| Setting
|
|
63
|
-
|
|
|
64
|
-
| `position`
|
|
65
|
-
| `iframeMode`
|
|
66
|
-
| `iconSize`
|
|
67
|
-
| `magnification`
|
|
68
|
-
| `magnificationScale`
|
|
69
|
-
| `showNightModeButton`
|
|
70
|
-
| `
|
|
64
|
+
| Setting | Default | Description |
|
|
65
|
+
| ---------------------- | ------------ | ------------------------------------------------------------------------------------------------ |
|
|
66
|
+
| `position` | `bottom` | Dock edge: `bottom`, `top`, `left`, `right` |
|
|
67
|
+
| `iframeMode` | `keep-alive` | `keep-alive` loads each app once and hides/shows (faster, more RAM); `destroy` reloads each time |
|
|
68
|
+
| `iconSize` | `56` | Base icon size in px |
|
|
69
|
+
| `magnification` | `true` | Enable macOS-style magnification effect |
|
|
70
|
+
| `magnificationScale` | `1.7` | Max icon scale (1.0-2.5) |
|
|
71
|
+
| `showNightModeButton` | `true` | Show sun/moon button in dock; PUT-writes `environment.mode` |
|
|
72
|
+
| `showFullscreenButton` | `false` | Show fullscreen toggle in dock; hidden automatically where the Fullscreen API is unavailable |
|
|
73
|
+
| `showExitButton` | `false` | Show X button that returns to the Signal K admin UI |
|
|
74
|
+
|
|
75
|
+
## Access
|
|
76
|
+
|
|
77
|
+
With security enabled, what the dock offers depends on the visitor:
|
|
78
|
+
|
|
79
|
+
| Visitor | Apps shown | Night-mode toggle | "Don't show again" in the tour |
|
|
80
|
+
| ---------------------------------------------- | ---------- | ----------------- | ------------------------------ |
|
|
81
|
+
| admin | configured | yes | yes |
|
|
82
|
+
| readwrite user | configured | yes | yes |
|
|
83
|
+
| readonly user | configured | no | no |
|
|
84
|
+
| not signed in, server allows read-only access | configured | no | no |
|
|
85
|
+
| not signed in, server refuses read-only access | built-in | no | no |
|
|
86
|
+
|
|
87
|
+
Visitors who are not admins reach the plugin's routes through `router.access()`, which signalk-server 2.31 added. On older servers they get the built-in list, the same apps the plugin seeds on its first start. Only admins can open **Plugin Config**, and "Don't show again" hides the tour for every visitor. Everyone who gets the configured apps can also read their URLs from the plugin's `/settings`, so keep credentials out of app URLs.
|
|
71
88
|
|
|
72
89
|
## Development
|
|
73
90
|
|
package/package.json
CHANGED
package/plugin/index.js
CHANGED
|
@@ -12,9 +12,9 @@ const DEFAULT_APPS = [
|
|
|
12
12
|
{
|
|
13
13
|
enabled: true,
|
|
14
14
|
autostart: false,
|
|
15
|
-
url: '/@
|
|
16
|
-
label: '
|
|
17
|
-
icon: '/@
|
|
15
|
+
url: '/@halos-org/skip/',
|
|
16
|
+
label: 'Skip',
|
|
17
|
+
icon: '/@halos-org/skip/assets/icon-72x72.png',
|
|
18
18
|
color: ''
|
|
19
19
|
},
|
|
20
20
|
{
|
|
@@ -30,6 +30,26 @@ const DEFAULT_APPS = [
|
|
|
30
30
|
module.exports = (app) => {
|
|
31
31
|
let pluginSettings = {}
|
|
32
32
|
let resolvedApps = []
|
|
33
|
+
let running = false
|
|
34
|
+
|
|
35
|
+
function resolveApps(settings) {
|
|
36
|
+
return (Array.isArray(settings.apps) ? settings.apps : [])
|
|
37
|
+
.filter((a) => a.enabled !== false)
|
|
38
|
+
.map((a) => ({
|
|
39
|
+
label: a.label || a.url,
|
|
40
|
+
url: a.url,
|
|
41
|
+
icon: a.icon || null,
|
|
42
|
+
color: a.color || null,
|
|
43
|
+
autostart: a.autostart || false
|
|
44
|
+
}))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The configuration as saved, the one /config serves. While the plugin is
|
|
48
|
+
// not running, pluginSettings is empty (never started) or holds what the
|
|
49
|
+
// last start() got, which a later save may have replaced.
|
|
50
|
+
function savedSettings() {
|
|
51
|
+
return app.readPluginOptions().configuration || {}
|
|
52
|
+
}
|
|
33
53
|
|
|
34
54
|
function getWebapps() {
|
|
35
55
|
return (app.webapps || [])
|
|
@@ -83,26 +103,27 @@ module.exports = (app) => {
|
|
|
83
103
|
)
|
|
84
104
|
}
|
|
85
105
|
|
|
86
|
-
resolvedApps = (settings
|
|
87
|
-
|
|
88
|
-
.map((a) => ({
|
|
89
|
-
label: a.label || a.url,
|
|
90
|
-
url: a.url,
|
|
91
|
-
icon: a.icon || null,
|
|
92
|
-
color: a.color || null,
|
|
93
|
-
autostart: a.autostart || false
|
|
94
|
-
}))
|
|
106
|
+
resolvedApps = resolveApps(settings)
|
|
107
|
+
running = true
|
|
95
108
|
|
|
96
109
|
app.debug('App Dock: resolved %d apps: %s', resolvedApps.length, resolvedApps.map((a) => a.label).join(', '))
|
|
97
110
|
},
|
|
98
111
|
|
|
99
|
-
stop() {
|
|
112
|
+
stop() {
|
|
113
|
+
running = false
|
|
114
|
+
},
|
|
100
115
|
|
|
101
116
|
registerWithRouter(router) {
|
|
102
|
-
router
|
|
117
|
+
// Routes registered on the router itself are admin-only. signalk-server
|
|
118
|
+
// 2.31 and later add router.access(), which opens a route to readonly or
|
|
119
|
+
// readwrite users; on older servers every route stays admin-only.
|
|
120
|
+
const openTo = (level) => (typeof router.access === 'function' ? router.access(level) : router)
|
|
121
|
+
|
|
122
|
+
openTo('readonly').get('/settings', (req, res) => {
|
|
123
|
+
const settings = running ? pluginSettings : savedSettings()
|
|
103
124
|
res.json({
|
|
104
|
-
...
|
|
105
|
-
apps: resolvedApps
|
|
125
|
+
...settings,
|
|
126
|
+
apps: running ? resolvedApps : resolveApps(settings)
|
|
106
127
|
})
|
|
107
128
|
})
|
|
108
129
|
|
|
@@ -110,13 +131,15 @@ module.exports = (app) => {
|
|
|
110
131
|
res.json(getWebapps())
|
|
111
132
|
})
|
|
112
133
|
|
|
113
|
-
|
|
134
|
+
openTo('readonly').get('/mode', (req, res) => {
|
|
114
135
|
const current = app.getSelfPath('environment.mode')
|
|
115
136
|
res.json({ value: (current && current.value) || 'day' })
|
|
116
137
|
})
|
|
117
138
|
|
|
118
|
-
|
|
119
|
-
|
|
139
|
+
openTo('readwrite').post('/dismiss-tour', (req, res) => {
|
|
140
|
+
// savePluginOptions replaces the whole saved configuration, so the
|
|
141
|
+
// update starts from a complete one.
|
|
142
|
+
const updated = { ...(running ? pluginSettings : savedSettings()), tourDismissed: true }
|
|
120
143
|
app.savePluginOptions(updated, (err) => {
|
|
121
144
|
if (err) {
|
|
122
145
|
app.error('App Dock: failed to save tourDismissed: ' + err.message)
|
package/public/dock.js
CHANGED
|
@@ -41,11 +41,12 @@
|
|
|
41
41
|
apps: []
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// Fallback app list
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// demo.signalk.org behaves sensibly for
|
|
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.
|
|
49
50
|
const NO_ADMIN_DEFAULTS = {
|
|
50
51
|
showNightModeButton: true,
|
|
51
52
|
apps: [
|
|
@@ -60,9 +61,9 @@
|
|
|
60
61
|
{
|
|
61
62
|
enabled: true,
|
|
62
63
|
autostart: false,
|
|
63
|
-
url: '/@
|
|
64
|
-
label: '
|
|
65
|
-
icon: '/@
|
|
64
|
+
url: '/@halos-org/skip/',
|
|
65
|
+
label: 'Skip',
|
|
66
|
+
icon: '/@halos-org/skip/assets/icon-72x72.png',
|
|
66
67
|
color: ''
|
|
67
68
|
},
|
|
68
69
|
{
|
|
@@ -76,24 +77,26 @@
|
|
|
76
77
|
]
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
// ─── Detect
|
|
80
|
-
// /skServer/loginStatus is public
|
|
81
|
-
// /
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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.
|
|
84
85
|
// When authenticationRequired is false (no-auth / dummy-security setups),
|
|
85
86
|
// /plugins/* is reachable without a login, so treat the visitor as
|
|
86
87
|
// full-access instead of falling through to the hardcoded fallback list.
|
|
87
88
|
// Try /skServer/loginStatus first, fall back to the legacy /loginStatus.
|
|
88
89
|
let noAdminAccess = false
|
|
90
|
+
let canWrite = true
|
|
89
91
|
for (const url of ['/skServer/loginStatus', '/loginStatus']) {
|
|
90
92
|
try {
|
|
91
93
|
const res = await fetch(url)
|
|
92
94
|
if (res.ok) {
|
|
93
95
|
const data = await res.json()
|
|
94
96
|
const authOff = data.authenticationRequired === false
|
|
95
|
-
const
|
|
96
|
-
noAdminAccess = !authOff &&
|
|
97
|
+
const level = data.status === 'loggedIn' ? data.userLevel : undefined
|
|
98
|
+
noAdminAccess = !authOff && level !== 'admin'
|
|
99
|
+
canWrite = authOff || level === 'admin' || level === 'readwrite'
|
|
97
100
|
break
|
|
98
101
|
}
|
|
99
102
|
} catch {
|
|
@@ -101,10 +104,31 @@
|
|
|
101
104
|
}
|
|
102
105
|
}
|
|
103
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
|
+
|
|
104
122
|
// ─── Load config from plugin endpoints ───────────────────────────────────────
|
|
105
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
|
|
106
128
|
if (noAdminAccess) {
|
|
107
|
-
|
|
129
|
+
const shared = await fetchSharedConfig()
|
|
130
|
+
pluginReachable = shared !== null
|
|
131
|
+
cfg = shared || { ...DEFAULTS, ...NO_ADMIN_DEFAULTS }
|
|
108
132
|
} else {
|
|
109
133
|
try {
|
|
110
134
|
const [configRes, settingsRes] = await Promise.all([
|
|
@@ -147,9 +171,13 @@
|
|
|
147
171
|
|
|
148
172
|
// ─── Night mode state ────────────────────────────────────────────────────────
|
|
149
173
|
let currentMode = 'day'
|
|
174
|
+
// The button reads the mode from the plugin's /mode and switches it with a
|
|
175
|
+
// PUT that signalk-server accepts from admins and readwrite users only, so
|
|
176
|
+
// only visitors who can do both get the button.
|
|
177
|
+
const nightModeUsable = pluginReachable && canWrite
|
|
150
178
|
|
|
151
179
|
async function fetchCurrentMode() {
|
|
152
|
-
if (
|
|
180
|
+
if (!pluginReachable) return
|
|
153
181
|
try {
|
|
154
182
|
const res = await fetch('/plugins/signalk-app-dock/mode')
|
|
155
183
|
if (res.ok) {
|
|
@@ -171,7 +199,7 @@
|
|
|
171
199
|
}
|
|
172
200
|
|
|
173
201
|
async function toggleNightMode() {
|
|
174
|
-
if (
|
|
202
|
+
if (!nightModeUsable) return
|
|
175
203
|
await fetchCurrentMode()
|
|
176
204
|
const newMode = currentMode === 'night' ? 'day' : 'night'
|
|
177
205
|
try {
|
|
@@ -338,7 +366,8 @@
|
|
|
338
366
|
const radius = Math.round(sz * 0.25) + 'px'
|
|
339
367
|
const lblCls = labelClass()
|
|
340
368
|
|
|
341
|
-
|
|
369
|
+
const showNightMode = cfg.showNightModeButton && nightModeUsable
|
|
370
|
+
if (showNightMode) {
|
|
342
371
|
$dockInner.appendChild(createUtilityItem('dock-item-nightmode', '\u2600\uFE0F', 'Night mode', toggleNightMode))
|
|
343
372
|
}
|
|
344
373
|
|
|
@@ -346,7 +375,7 @@
|
|
|
346
375
|
$dockInner.appendChild(createUtilityItem('dock-item-fullscreen', '\u26F6', 'Fullscreen', toggleFullscreen))
|
|
347
376
|
}
|
|
348
377
|
|
|
349
|
-
if (
|
|
378
|
+
if (showNightMode || (cfg.showFullscreenButton && fullscreenSupported())) {
|
|
350
379
|
$dockInner.appendChild(createSeparator())
|
|
351
380
|
}
|
|
352
381
|
|
|
@@ -587,8 +616,10 @@
|
|
|
587
616
|
|
|
588
617
|
hideIdleHint()
|
|
589
618
|
|
|
590
|
-
|
|
591
|
-
|
|
619
|
+
// The night-mode and fullscreen buttons are .dock-items too; only app
|
|
620
|
+
// items carry a data-index.
|
|
621
|
+
document.querySelectorAll('.dock-item').forEach((el) => {
|
|
622
|
+
el.classList.toggle('active', el.dataset.index === String(index))
|
|
592
623
|
})
|
|
593
624
|
|
|
594
625
|
const alreadyLoaded = !!iframes[app.url]
|
|
@@ -767,7 +798,7 @@
|
|
|
767
798
|
// ─── Init ────────────────────────────────────────────────────────────────────
|
|
768
799
|
buildDock()
|
|
769
800
|
|
|
770
|
-
if (cfg.showNightModeButton &&
|
|
801
|
+
if (cfg.showNightModeButton && nightModeUsable) {
|
|
771
802
|
fetchCurrentMode()
|
|
772
803
|
setInterval(fetchCurrentMode, 5000)
|
|
773
804
|
}
|
|
@@ -791,21 +822,25 @@
|
|
|
791
822
|
|
|
792
823
|
async function refreshConfig() {
|
|
793
824
|
try {
|
|
794
|
-
const [configRes, settingsRes] = await Promise.all([
|
|
795
|
-
fetch('/plugins/signalk-app-dock/config'),
|
|
796
|
-
fetch('/plugins/signalk-app-dock/settings')
|
|
797
|
-
])
|
|
798
825
|
const prev = cfg
|
|
799
826
|
let next = cfg
|
|
800
|
-
if (
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
if (
|
|
808
|
-
|
|
827
|
+
if (noAdminAccess) {
|
|
828
|
+
next = (await fetchSharedConfig()) || prev
|
|
829
|
+
} else {
|
|
830
|
+
const [configRes, settingsRes] = await Promise.all([
|
|
831
|
+
fetch('/plugins/signalk-app-dock/config'),
|
|
832
|
+
fetch('/plugins/signalk-app-dock/settings')
|
|
833
|
+
])
|
|
834
|
+
if (configRes.ok) {
|
|
835
|
+
const data = await configRes.json()
|
|
836
|
+
const pluginCfg = data.configuration || data
|
|
837
|
+
next = { ...DEFAULTS, ...pluginCfg, apps: prev.apps }
|
|
838
|
+
}
|
|
839
|
+
if (settingsRes.ok) {
|
|
840
|
+
const data = await settingsRes.json()
|
|
841
|
+
if (Array.isArray(data.apps) && data.apps.length > 0) {
|
|
842
|
+
next = { ...next, apps: data.apps }
|
|
843
|
+
}
|
|
809
844
|
}
|
|
810
845
|
}
|
|
811
846
|
const structuralChanged = STRUCTURAL_KEYS.some((k) => prev[k] !== next[k])
|
|
@@ -824,7 +859,7 @@
|
|
|
824
859
|
}
|
|
825
860
|
}
|
|
826
861
|
|
|
827
|
-
if (
|
|
862
|
+
if (pluginReachable) setInterval(refreshConfig, 5000)
|
|
828
863
|
|
|
829
864
|
// ─── Welcome tour ────────────────────────────────────────────────────────────
|
|
830
865
|
const $tourOverlay = document.getElementById('tour-overlay')
|
|
@@ -843,11 +878,13 @@
|
|
|
843
878
|
|
|
844
879
|
if ($tourGotIt) $tourGotIt.addEventListener('click', hideTour)
|
|
845
880
|
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
|
|
881
|
+
// "Don't show again" POSTs /dismiss-tour, which saves tourDismissed in the
|
|
882
|
+
// plugin configuration, so it applies to every user. The plugin opens that
|
|
883
|
+
// route to readwrite users. Everyone else who is not an admin, and anyone
|
|
884
|
+
// /settings did not serve the dock to, gets no button rather than one that
|
|
885
|
+
// advertises a setting we can't honour. The tour reappears on their next
|
|
886
|
+
// visit.
|
|
887
|
+
if (!pluginReachable || !canWrite) {
|
|
851
888
|
if ($tourDismiss) $tourDismiss.remove()
|
|
852
889
|
document.getElementById('tour-buttons')?.style.setProperty('justify-content', 'center')
|
|
853
890
|
} else if ($tourDismiss) {
|
package/public/index.html
CHANGED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
<strong>Double-tap anywhere</strong> on the screen to open the dock, then tap an icon to launch that app.
|
|
53
53
|
</p>
|
|
54
54
|
<p>
|
|
55
|
-
Freeboard-SK is loading in the background and
|
|
55
|
+
Freeboard-SK is loading in the background and Skip is one tap away. Customize the apps, dock position, and
|
|
56
56
|
more in
|
|
57
57
|
<a id="tour-link" href="#">Plugin Config</a>.
|
|
58
58
|
</p>
|
package/test/plugin.test.js
CHANGED
|
@@ -13,6 +13,7 @@ function createMockApp(webapps = []) {
|
|
|
13
13
|
debug: () => {},
|
|
14
14
|
error: () => {},
|
|
15
15
|
savePluginOptions: (opts, cb) => cb(null),
|
|
16
|
+
readPluginOptions: () => ({}),
|
|
16
17
|
registerPutHandler: () => {},
|
|
17
18
|
handleMessage: () => {},
|
|
18
19
|
getSelfPath: () => null
|
|
@@ -220,6 +221,85 @@ describe('settings endpoint', () => {
|
|
|
220
221
|
}
|
|
221
222
|
routes['/settings']({}, res)
|
|
222
223
|
})
|
|
224
|
+
|
|
225
|
+
it('serves the saved configuration before the plugin has started', (t, done) => {
|
|
226
|
+
const app = createMockApp()
|
|
227
|
+
app.readPluginOptions = () => ({
|
|
228
|
+
enabled: false,
|
|
229
|
+
configuration: {
|
|
230
|
+
position: 'right',
|
|
231
|
+
apps: [
|
|
232
|
+
{ url: '/a/', label: 'A', enabled: true },
|
|
233
|
+
{ url: '/b/', label: 'B', enabled: false }
|
|
234
|
+
]
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
const plugin = pluginFactory(app)
|
|
238
|
+
|
|
239
|
+
const routes = {}
|
|
240
|
+
plugin.registerWithRouter({
|
|
241
|
+
get: (path, handler) => {
|
|
242
|
+
routes[path] = handler
|
|
243
|
+
},
|
|
244
|
+
post: () => {}
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const res = {
|
|
248
|
+
json: (data) => {
|
|
249
|
+
assert.equal(data.position, 'right')
|
|
250
|
+
assert.deepEqual(data.apps, [{ label: 'A', url: '/a/', icon: null, color: null, autostart: false }])
|
|
251
|
+
done()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
routes['/settings']({}, res)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('serves no apps when the saved apps value is not an array', (t, done) => {
|
|
258
|
+
const app = createMockApp()
|
|
259
|
+
app.readPluginOptions = () => ({ enabled: false, configuration: { apps: {} } })
|
|
260
|
+
const plugin = pluginFactory(app)
|
|
261
|
+
|
|
262
|
+
const routes = {}
|
|
263
|
+
plugin.registerWithRouter({
|
|
264
|
+
get: (path, handler) => {
|
|
265
|
+
routes[path] = handler
|
|
266
|
+
},
|
|
267
|
+
post: () => {}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
const res = {
|
|
271
|
+
json: (data) => {
|
|
272
|
+
assert.deepEqual(data.apps, [])
|
|
273
|
+
done()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
routes['/settings']({}, res)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('serves the saved configuration after stop()', (t, done) => {
|
|
280
|
+
const app = createMockApp()
|
|
281
|
+
app.readPluginOptions = () => ({ enabled: false, configuration: { position: 'top', apps: [] } })
|
|
282
|
+
const plugin = pluginFactory(app)
|
|
283
|
+
plugin.start({ position: 'left', apps: [{ url: '/a/', label: 'A' }] })
|
|
284
|
+
plugin.stop()
|
|
285
|
+
|
|
286
|
+
const routes = {}
|
|
287
|
+
plugin.registerWithRouter({
|
|
288
|
+
get: (path, handler) => {
|
|
289
|
+
routes[path] = handler
|
|
290
|
+
},
|
|
291
|
+
post: () => {}
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
const res = {
|
|
295
|
+
json: (data) => {
|
|
296
|
+
assert.equal(data.position, 'top')
|
|
297
|
+
assert.deepEqual(data.apps, [])
|
|
298
|
+
done()
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
routes['/settings']({}, res)
|
|
302
|
+
})
|
|
223
303
|
})
|
|
224
304
|
|
|
225
305
|
describe('default apps seeding', () => {
|
|
@@ -237,7 +317,7 @@ describe('default apps seeding', () => {
|
|
|
237
317
|
assert.ok(savedOptions)
|
|
238
318
|
assert.equal(savedOptions.apps[0].url, '/@signalk/freeboard-sk/')
|
|
239
319
|
assert.equal(savedOptions.apps[0].autostart, true)
|
|
240
|
-
assert.equal(savedOptions.apps[1].url, '/@
|
|
320
|
+
assert.equal(savedOptions.apps[1].url, '/@halos-org/skip/')
|
|
241
321
|
assert.equal(savedOptions.apps[1].autostart, false)
|
|
242
322
|
assert.equal(savedOptions.apps[2].url, '/admin/')
|
|
243
323
|
assert.equal(savedOptions.apps[2].label, 'Settings')
|
|
@@ -445,6 +525,35 @@ describe('dismiss-tour endpoint', () => {
|
|
|
445
525
|
}
|
|
446
526
|
routes.post['/dismiss-tour']({}, res)
|
|
447
527
|
})
|
|
528
|
+
|
|
529
|
+
it('keeps the saved configuration when the plugin is not running', (t, done) => {
|
|
530
|
+
const app = createMockApp()
|
|
531
|
+
const apps = [{ url: '/a/', label: 'A', enabled: true }]
|
|
532
|
+
app.readPluginOptions = () => ({ enabled: false, configuration: { position: 'right', apps } })
|
|
533
|
+
let saved = null
|
|
534
|
+
app.savePluginOptions = (opts, cb) => {
|
|
535
|
+
saved = opts
|
|
536
|
+
cb(null)
|
|
537
|
+
}
|
|
538
|
+
const plugin = pluginFactory(app)
|
|
539
|
+
|
|
540
|
+
const routes = {}
|
|
541
|
+
plugin.registerWithRouter({
|
|
542
|
+
get: () => {},
|
|
543
|
+
post: (path, handler) => {
|
|
544
|
+
routes[path] = handler
|
|
545
|
+
}
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
const res = {
|
|
549
|
+
json: (data) => {
|
|
550
|
+
assert.equal(data.tourDismissed, true)
|
|
551
|
+
assert.deepEqual(saved, { position: 'right', apps, tourDismissed: true })
|
|
552
|
+
done()
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
routes['/dismiss-tour']({}, res)
|
|
556
|
+
})
|
|
448
557
|
})
|
|
449
558
|
|
|
450
559
|
describe('mode endpoint', () => {
|
|
@@ -495,6 +604,51 @@ describe('mode endpoint', () => {
|
|
|
495
604
|
})
|
|
496
605
|
})
|
|
497
606
|
|
|
607
|
+
describe('route access', () => {
|
|
608
|
+
// Records each route with the level it is registered at: 'admin' for a
|
|
609
|
+
// route registered on the router itself, the access() level otherwise.
|
|
610
|
+
function recordingRouter({ withAccess }) {
|
|
611
|
+
const levels = {}
|
|
612
|
+
const registrar = (level) => ({
|
|
613
|
+
get: (path) => {
|
|
614
|
+
levels[`GET ${path}`] = level
|
|
615
|
+
},
|
|
616
|
+
post: (path) => {
|
|
617
|
+
levels[`POST ${path}`] = level
|
|
618
|
+
}
|
|
619
|
+
})
|
|
620
|
+
const router = registrar('admin')
|
|
621
|
+
if (withAccess) router.access = registrar
|
|
622
|
+
return { router, levels }
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
it('opens /settings and /mode to readonly users and /dismiss-tour to readwrite users', () => {
|
|
626
|
+
const plugin = pluginFactory(createMockApp())
|
|
627
|
+
const { router, levels } = recordingRouter({ withAccess: true })
|
|
628
|
+
plugin.registerWithRouter(router)
|
|
629
|
+
|
|
630
|
+
assert.deepEqual(levels, {
|
|
631
|
+
'GET /settings': 'readonly',
|
|
632
|
+
'GET /webapps': 'admin',
|
|
633
|
+
'GET /mode': 'readonly',
|
|
634
|
+
'POST /dismiss-tour': 'readwrite'
|
|
635
|
+
})
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
it('registers every route on the router itself when it has no access()', () => {
|
|
639
|
+
const plugin = pluginFactory(createMockApp())
|
|
640
|
+
const { router, levels } = recordingRouter({ withAccess: false })
|
|
641
|
+
plugin.registerWithRouter(router)
|
|
642
|
+
|
|
643
|
+
assert.deepEqual(levels, {
|
|
644
|
+
'GET /settings': 'admin',
|
|
645
|
+
'GET /webapps': 'admin',
|
|
646
|
+
'GET /mode': 'admin',
|
|
647
|
+
'POST /dismiss-tour': 'admin'
|
|
648
|
+
})
|
|
649
|
+
})
|
|
650
|
+
})
|
|
651
|
+
|
|
498
652
|
describe('public files', () => {
|
|
499
653
|
const publicDir = path.join(__dirname, '..', 'public')
|
|
500
654
|
|