@signalk/app-dock 0.2.0 → 0.3.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 +32 -23
- package/eslint.config.js +2 -1
- package/package.json +1 -1
- package/plugin/index.js +76 -52
- package/public/805.js +1 -1
- package/public/app-icon.svg +5 -3
- package/public/dock.css +113 -14
- package/public/dock.js +192 -76
- package/public/index.html +22 -3
- package/src/configpanel/PluginConfigurationPanel.js +38 -22
- package/test/plugin.test.js +84 -105
package/README.md
CHANGED
|
@@ -9,10 +9,10 @@ A macOS-style app dock for switching between Signal K webapps on touch screens.
|
|
|
9
9
|
- **macOS dock magnification** -- icons scale up with parabolic falloff as your finger moves along the dock
|
|
10
10
|
- **Frosted glass pill** with spring bounce animation, positioned on any screen edge
|
|
11
11
|
- **Lazy loading** -- apps load only when first tapped (important for Raspberry Pi and low-power devices)
|
|
12
|
-
- **Double-tap
|
|
13
|
-
- **Mouse edge hover** to reveal on desktop
|
|
12
|
+
- **Double-tap anywhere** to reveal the dock -- works on touch and mouse. Single taps and clicks pass through to the underlying webapp unchanged, so interactive elements (e.g. KIP's corner widgets) keep working. The pass-through recurses into nested iframes too, so double-tap still opens the dock when one webapp embeds another (e.g. KIP's split view with Freeboard-SK).
|
|
14
13
|
- **Autostart** -- optionally load a default app immediately on open
|
|
15
14
|
- **keep-alive or destroy** iframe lifecycle
|
|
15
|
+
- **Night/day mode toggle** -- optional sun/moon button that flips `environment.mode` via a PUT handler
|
|
16
16
|
- **Active dot indicator**, label tooltip, haptic feedback
|
|
17
17
|
- **Embedded config panel** in the admin UI with webapp discovery, drag-to-reorder, and live preview
|
|
18
18
|
|
|
@@ -29,9 +29,11 @@ Open: `http://your-sk-server:3000/@signalk/app-dock/`
|
|
|
29
29
|
|
|
30
30
|
## Usage
|
|
31
31
|
|
|
32
|
-
**Double-tap** the
|
|
32
|
+
**Double-tap anywhere** on the screen to open the dock. Tap an app icon to switch. The dock auto-dismisses after selection, or tap the backdrop to close it. Double-click works the same way for mouse users.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Tip: double-tapping a clickable element in the underlying webapp will activate that element once before the dock opens -- aim double-taps at non-interactive regions (maps, gauge backgrounds, empty space).
|
|
35
|
+
|
|
36
|
+
Config changes made in **Plugin Config** apply automatically within ~5 seconds: structural changes (position, icon size, etc.) reload the dock page; changes to the app list hot-update in place.
|
|
35
37
|
|
|
36
38
|
## Configuration
|
|
37
39
|
|
|
@@ -45,38 +47,45 @@ Open **Plugin Config > App Dock** in the admin UI. The embedded configurator pro
|
|
|
45
47
|
|
|
46
48
|
### Settings
|
|
47
49
|
|
|
48
|
-
| Setting
|
|
49
|
-
|
|
|
50
|
-
| `position`
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
50
|
+
| Setting | Default | Description |
|
|
51
|
+
| --------------------- | ------------ | ------------------------------------------------------------------------------------------------ |
|
|
52
|
+
| `position` | `bottom` | Dock edge: `bottom`, `top`, `left`, `right` |
|
|
53
|
+
| `iframeMode` | `keep-alive` | `keep-alive` loads each app once and hides/shows (faster, more RAM); `destroy` reloads each time |
|
|
54
|
+
| `iconSize` | `56` | Base icon size in px |
|
|
55
|
+
| `magnification` | `true` | Enable macOS-style magnification effect |
|
|
56
|
+
| `magnificationScale` | `1.7` | Max icon scale (1.0-2.5) |
|
|
57
|
+
| `showNightModeButton` | `true` | Show sun/moon button in dock; PUT-writes `environment.mode` |
|
|
58
|
+
| `showExitButton` | `false` | Show X button that returns to the Signal K admin UI |
|
|
56
59
|
|
|
57
60
|
## Development
|
|
58
61
|
|
|
59
62
|
```bash
|
|
60
|
-
cd ~/.signalk
|
|
63
|
+
cd ~/.signalk # or a throwaway SK config dir for dev, e.g. ~/.signalk-app-dock
|
|
61
64
|
npm link /path/to/app-dock
|
|
62
65
|
```
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
To rebuild the config panel (after changing `src/configpanel/`):
|
|
67
|
+
For side-by-side development without touching your real SK setup, point the server at a dedicated config dir:
|
|
67
68
|
|
|
68
69
|
```bash
|
|
69
|
-
npm
|
|
70
|
+
PORT=4000 npm start -- -c ~/.signalk-app-dock
|
|
70
71
|
```
|
|
71
72
|
|
|
73
|
+
### Architecture
|
|
74
|
+
|
|
75
|
+
The repo ships three cooperating surfaces:
|
|
76
|
+
|
|
77
|
+
- **`plugin/index.js`** -- CommonJS SK-server plugin. Registers the schema, seeds default apps on first run, exposes HTTP endpoints for the dock and config panel, and owns the `environment.mode` PUT handler.
|
|
78
|
+
- **`public/dock.js` + `dock.css` + `index.html`** -- the dock UI itself. Plain JS, served as-is. **No build step**: edit and reload the browser.
|
|
79
|
+
- **`src/configpanel/`** -- React 19 component exposed to the admin UI via Webpack Module Federation. Rebuild with `npm run build:config` after changes here; output lands in `public/remoteEntry.js` and companion chunks.
|
|
80
|
+
|
|
72
81
|
### Scripts
|
|
73
82
|
|
|
74
|
-
| Command | Description
|
|
75
|
-
| ---------------------- |
|
|
76
|
-
| `npm test` | Run tests
|
|
77
|
-
| `npm run format` | Prettier + ESLint fix
|
|
78
|
-
| `npm run lint` | ESLint check
|
|
79
|
-
| `npm run build:config` | Rebuild the admin UI config panel |
|
|
83
|
+
| Command | Description |
|
|
84
|
+
| ---------------------- | ----------------------------------------------------------------- |
|
|
85
|
+
| `npm test` | Run plugin tests (`node --test`) |
|
|
86
|
+
| `npm run format` | Prettier + ESLint fix |
|
|
87
|
+
| `npm run lint` | ESLint check |
|
|
88
|
+
| `npm run build:config` | Rebuild the admin UI config panel (required after `src/` changes) |
|
|
80
89
|
|
|
81
90
|
## License
|
|
82
91
|
|
package/eslint.config.js
CHANGED
package/package.json
CHANGED
package/plugin/index.js
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const DEFAULT_APPS = [
|
|
4
|
+
{
|
|
5
|
+
enabled: true,
|
|
6
|
+
autostart: true,
|
|
7
|
+
url: '/@signalk/freeboard-sk/',
|
|
8
|
+
label: 'Freeboard-SK',
|
|
9
|
+
icon: '/@signalk/freeboard-sk/assets/icons/icon-72x72.png',
|
|
10
|
+
color: ''
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
enabled: true,
|
|
14
|
+
autostart: false,
|
|
15
|
+
url: '/@mxtommy/kip/',
|
|
16
|
+
label: 'KIP',
|
|
17
|
+
icon: '/@mxtommy/kip/assets/icon-72x72.png',
|
|
18
|
+
color: ''
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
enabled: true,
|
|
22
|
+
autostart: false,
|
|
23
|
+
url: '/admin/',
|
|
24
|
+
label: 'Settings',
|
|
25
|
+
icon: '/@signalk/app-dock/icon-settings.svg',
|
|
26
|
+
color: '#78788c'
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
|
|
3
30
|
module.exports = (app) => {
|
|
4
31
|
let pluginSettings = {}
|
|
5
32
|
let resolvedApps = []
|
|
@@ -20,6 +47,22 @@ module.exports = (app) => {
|
|
|
20
47
|
name: 'App Dock',
|
|
21
48
|
|
|
22
49
|
start(settings) {
|
|
50
|
+
if (!Array.isArray(settings.apps)) {
|
|
51
|
+
const seeded = DEFAULT_APPS.map((a) => ({ ...a }))
|
|
52
|
+
const seededSettings = {
|
|
53
|
+
showNightModeButton: true,
|
|
54
|
+
showExitButton: false,
|
|
55
|
+
...settings,
|
|
56
|
+
apps: seeded
|
|
57
|
+
}
|
|
58
|
+
app.debug('App Dock: first run, seeding %d default apps', seeded.length)
|
|
59
|
+
app.savePluginOptions(seededSettings, (err) => {
|
|
60
|
+
if (err) app.error('App Dock: failed to save default apps: ' + err.message)
|
|
61
|
+
else app.debug('App Dock: saved default apps to config')
|
|
62
|
+
})
|
|
63
|
+
settings = seededSettings
|
|
64
|
+
}
|
|
65
|
+
|
|
23
66
|
pluginSettings = settings
|
|
24
67
|
|
|
25
68
|
if (settings.showNightModeButton) {
|
|
@@ -39,49 +82,17 @@ module.exports = (app) => {
|
|
|
39
82
|
)
|
|
40
83
|
}
|
|
41
84
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
merged.push({ enabled: true, url: w.url, label: '', icon: '', color: '' })
|
|
54
|
-
added = true
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const discoveredByUrl = {}
|
|
59
|
-
discovered.forEach((d) => {
|
|
60
|
-
discoveredByUrl[d.url] = d
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
resolvedApps = merged
|
|
64
|
-
.filter((a) => a.enabled !== false)
|
|
65
|
-
.map((a) => {
|
|
66
|
-
const match = discoveredByUrl[a.url]
|
|
67
|
-
return {
|
|
68
|
-
label: a.label || (match && match.label) || a.url,
|
|
69
|
-
url: a.url,
|
|
70
|
-
icon: a.icon || (match && match.icon) || null,
|
|
71
|
-
color: a.color || null,
|
|
72
|
-
autostart: a.autostart || false
|
|
73
|
-
}
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
if (added) {
|
|
77
|
-
app.savePluginOptions({ ...settings, apps: merged }, (err) => {
|
|
78
|
-
if (err) app.error('Failed to save discovered apps: ' + err.message)
|
|
79
|
-
else app.debug('Auto-discovered new webapps, saved to config')
|
|
80
|
-
})
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
app.debug('Dock apps: %s', resolvedApps.map((a) => a.label).join(', '))
|
|
84
|
-
}, 5000)
|
|
85
|
+
resolvedApps = (settings.apps || [])
|
|
86
|
+
.filter((a) => a.enabled !== false)
|
|
87
|
+
.map((a) => ({
|
|
88
|
+
label: a.label || a.url,
|
|
89
|
+
url: a.url,
|
|
90
|
+
icon: a.icon || null,
|
|
91
|
+
color: a.color || null,
|
|
92
|
+
autostart: a.autostart || false
|
|
93
|
+
}))
|
|
94
|
+
|
|
95
|
+
app.debug('App Dock: resolved %d apps: %s', resolvedApps.length, resolvedApps.map((a) => a.label).join(', '))
|
|
85
96
|
},
|
|
86
97
|
|
|
87
98
|
stop() {},
|
|
@@ -102,6 +113,19 @@ module.exports = (app) => {
|
|
|
102
113
|
const current = app.getSelfPath('environment.mode')
|
|
103
114
|
res.json({ value: (current && current.value) || 'day' })
|
|
104
115
|
})
|
|
116
|
+
|
|
117
|
+
router.post('/dismiss-tour', (req, res) => {
|
|
118
|
+
const updated = { ...pluginSettings, tourDismissed: true }
|
|
119
|
+
app.savePluginOptions(updated, (err) => {
|
|
120
|
+
if (err) {
|
|
121
|
+
app.error('App Dock: failed to save tourDismissed: ' + err.message)
|
|
122
|
+
res.status(500).json({ error: err.message })
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
pluginSettings = updated
|
|
126
|
+
res.json({ tourDismissed: true })
|
|
127
|
+
})
|
|
128
|
+
})
|
|
105
129
|
},
|
|
106
130
|
|
|
107
131
|
schema: {
|
|
@@ -117,14 +141,6 @@ module.exports = (app) => {
|
|
|
117
141
|
default: 'bottom'
|
|
118
142
|
},
|
|
119
143
|
|
|
120
|
-
triggerCorner: {
|
|
121
|
-
type: 'string',
|
|
122
|
-
title: 'Double-tap corner',
|
|
123
|
-
description: 'Screen corner that activates the dock on double-tap',
|
|
124
|
-
enum: ['bottom-right', 'bottom-left', 'top-right', 'top-left'],
|
|
125
|
-
default: 'bottom-right'
|
|
126
|
-
},
|
|
127
|
-
|
|
128
144
|
iframeMode: {
|
|
129
145
|
type: 'string',
|
|
130
146
|
title: 'iFrame lifecycle',
|
|
@@ -157,7 +173,7 @@ module.exports = (app) => {
|
|
|
157
173
|
type: 'boolean',
|
|
158
174
|
title: 'Show night/day mode toggle',
|
|
159
175
|
description: 'Adds a sun/moon button to the dock that toggles environment.mode',
|
|
160
|
-
default:
|
|
176
|
+
default: true
|
|
161
177
|
},
|
|
162
178
|
|
|
163
179
|
showExitButton: {
|
|
@@ -167,6 +183,14 @@ module.exports = (app) => {
|
|
|
167
183
|
default: false
|
|
168
184
|
},
|
|
169
185
|
|
|
186
|
+
tourDismissed: {
|
|
187
|
+
type: 'boolean',
|
|
188
|
+
title: 'Welcome tour dismissed',
|
|
189
|
+
description:
|
|
190
|
+
'Set automatically when a user clicks "Don\'t show again" in the welcome tour. Uncheck to show the tour again on all devices.',
|
|
191
|
+
default: false
|
|
192
|
+
},
|
|
193
|
+
|
|
170
194
|
apps: {
|
|
171
195
|
type: 'array',
|
|
172
196
|
title: 'Dock Apps',
|
package/public/805.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";(self.webpackChunk_signalk_app_dock=self.webpackChunk_signalk_app_dock||[]).push([[805],{805(e,t,n){n.r(t),n.d(t,{default:()=>s});var a=n(231);const l={root:{fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',color:"#333",padding:"16px 0"},sectionTitle:{fontSize:13,fontWeight:600,color:"#888",textTransform:"uppercase",letterSpacing:"0.05em",marginBottom:10,marginTop:24},btn:{display:"inline-flex",alignItems:"center",gap:8,padding:"8px 16px",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},btnPrimary:{background:"#3b82f6",color:"#fff"},btnSave:{background:"#10b981",color:"#fff"},btnDanger:{background:"#ef4444",color:"#fff",padding:"4px 10px",fontSize:11},status:{marginTop:8,fontSize:12,minHeight:18},item:{display:"flex",alignItems:"center",gap:10,padding:"8px 12px",background:"#f8f9fa",border:"1px solid #e0e0e0",borderRadius:8,marginBottom:6,cursor:"grab"},itemDisabled:{opacity:.4},dragOver:{borderColor:"#3b82f6",background:"#eef4ff"},handle:{color:"#bbb",fontSize:16,cursor:"grab",userSelect:"none",flexShrink:0,width:18,textAlign:"center"},iconBox:{width:36,height:36,borderRadius:8,display:"flex",alignItems:"center",justifyContent:"center",background:"#e2e8f0",overflow:"hidden",flexShrink:0,fontSize:18},iconImg:{width:"100%",height:"100%",objectFit:"contain",padding:5},info:{flex:1,minWidth:0},name:{fontSize:13,fontWeight:600,color:"#333"},url:{fontSize:10,color:"#999",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},toggle:{flexShrink:0},checkbox:{width:16,height:16,cursor:"pointer",accentColor:"#3b82f6"},preview:{display:"flex",alignItems:"flex-end",justifyContent:"center",gap:6,padding:"12px 18px",background:"rgba(30,30,32,0.85)",border:"1px solid rgba(255,255,255,0.18)",borderRadius:18,margin:"16px auto",width:"fit-content"},actions:{display:"flex",gap:10,alignItems:"center",marginTop:16},empty:{textAlign:"center",padding:"30px 16px",color:"#999",fontSize:13},fieldRow:{display:"flex",alignItems:"center",gap:12,marginBottom:10},label:{fontSize:13,fontWeight:500,color:"#555",width:160,flexShrink:0},select:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,background:"#fff",color:"#333"},input:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,width:80,background:"#fff",color:"#333"},checkboxField:{width:16,height:16,accentColor:"#3b82f6"},hint:{fontSize:11,color:"#aaa",marginLeft:8}};function o({icon:e,label:t,color:n,size:o}){const i=o||36,r={...l.iconBox,width:i,height:i,borderRadius:.22*i,...n?{background:n}:{}},c=e||"";return c.startsWith("/")||c.startsWith("http")?a.createElement("div",{style:r},a.createElement("img",{src:c,alt:t,style:{...l.iconImg,padding:.12*i},onError:e=>{e.target.style.display="none",e.target.parentNode.textContent=(t||"??").slice(0,2).toUpperCase()}})):a.createElement("div",{style:r},c||(t||"??").slice(0,2).toUpperCase())}function i({label:e,value:t,options:n,onChange:o,hint:i}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("select",{style:l.select,value:t,onChange:e=>o(e.target.value)},n.map(e=>a.createElement("option",{key:e.value,value:e.value},e.label))),i&&a.createElement("span",{style:l.hint},i))}function r({label:e,value:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{style:l.input,type:"number",value:t,onChange:e=>n(Number(e.target.value))}),o&&a.createElement("span",{style:l.hint},o))}function c({label:e,checked:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{type:"checkbox",style:l.checkboxField,checked:t,onChange:e=>n(e.target.checked)}),o&&a.createElement("span",{style:l.hint},o))}function s({configuration:e,save:t}){const n=e||{},[s,d]=(0,a.useState)(()=>n.apps||[]),[g
|
|
1
|
+
"use strict";(self.webpackChunk_signalk_app_dock=self.webpackChunk_signalk_app_dock||[]).push([[805],{805(e,t,n){n.r(t),n.d(t,{default:()=>s});var a=n(231);const l={root:{fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',color:"#333",padding:"16px 0"},sectionTitle:{fontSize:13,fontWeight:600,color:"#888",textTransform:"uppercase",letterSpacing:"0.05em",marginBottom:10,marginTop:24},btn:{display:"inline-flex",alignItems:"center",gap:8,padding:"8px 16px",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},btnPrimary:{background:"#3b82f6",color:"#fff"},btnSave:{background:"#10b981",color:"#fff"},btnDanger:{background:"#ef4444",color:"#fff",padding:"4px 10px",fontSize:11},status:{marginTop:8,fontSize:12,minHeight:18},item:{display:"flex",alignItems:"center",gap:10,padding:"8px 12px",background:"#f8f9fa",border:"1px solid #e0e0e0",borderRadius:8,marginBottom:6,cursor:"grab"},itemDisabled:{opacity:.4},dragOver:{borderColor:"#3b82f6",background:"#eef4ff"},handle:{color:"#bbb",fontSize:16,cursor:"grab",userSelect:"none",flexShrink:0,width:18,textAlign:"center"},iconBox:{width:36,height:36,borderRadius:8,display:"flex",alignItems:"center",justifyContent:"center",background:"#e2e8f0",overflow:"hidden",flexShrink:0,fontSize:18},iconImg:{width:"100%",height:"100%",objectFit:"contain",padding:5},info:{flex:1,minWidth:0},name:{fontSize:13,fontWeight:600,color:"#333"},url:{fontSize:10,color:"#999",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},toggle:{flexShrink:0},checkbox:{width:16,height:16,cursor:"pointer",accentColor:"#3b82f6"},preview:{display:"flex",alignItems:"flex-end",justifyContent:"center",gap:6,padding:"12px 18px",background:"rgba(30,30,32,0.85)",border:"1px solid rgba(255,255,255,0.18)",borderRadius:18,margin:"16px auto",width:"fit-content"},actions:{display:"flex",gap:10,alignItems:"center",marginTop:16},empty:{textAlign:"center",padding:"30px 16px",color:"#999",fontSize:13},fieldRow:{display:"flex",alignItems:"center",gap:12,marginBottom:10},label:{fontSize:13,fontWeight:500,color:"#555",width:160,flexShrink:0},select:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,background:"#fff",color:"#333"},input:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,width:80,background:"#fff",color:"#333"},checkboxField:{width:16,height:16,accentColor:"#3b82f6"},hint:{fontSize:11,color:"#aaa",marginLeft:8}};function o({icon:e,label:t,color:n,size:o}){const i=o||36,r={...l.iconBox,width:i,height:i,borderRadius:.22*i,...n?{background:n}:{}},c=e||"";return c.startsWith("/")||c.startsWith("http")?a.createElement("div",{style:r},a.createElement("img",{src:c,alt:t,style:{...l.iconImg,padding:.12*i},onError:e=>{e.target.style.display="none",e.target.parentNode.textContent=(t||"??").slice(0,2).toUpperCase()}})):a.createElement("div",{style:r},c||(t||"??").slice(0,2).toUpperCase())}function i({label:e,value:t,options:n,onChange:o,hint:i}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("select",{style:l.select,value:t,onChange:e=>o(e.target.value)},n.map(e=>a.createElement("option",{key:e.value,value:e.value},e.label))),i&&a.createElement("span",{style:l.hint},i))}function r({label:e,value:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{style:l.input,type:"number",value:t,onChange:e=>n(Number(e.target.value))}),o&&a.createElement("span",{style:l.hint},o))}function c({label:e,checked:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{type:"checkbox",style:l.checkboxField,checked:t,onChange:e=>n(e.target.checked)}),o&&a.createElement("span",{style:l.hint},o))}function s({configuration:e,save:t}){const n=e||{},[s,d]=(0,a.useState)(()=>n.apps||[]),[u,g]=(0,a.useState)(n.position||"bottom"),[p,m]=(0,a.useState)(n.iframeMode||"keep-alive"),[b,f]=(0,a.useState)(n.iconSize||56),[h,y]=(0,a.useState)(!1!==n.magnification),[v,k]=(0,a.useState)(n.magnificationScale||1.7),[E,S]=(0,a.useState)(n.showNightModeButton||!1),[x,w]=(0,a.useState)(n.showExitButton||!1),[C,D]=(0,a.useState)(n.tourDismissed||!1),[z,R]=(0,a.useState)(""),[I,B]=(0,a.useState)(!1),[T,F]=(0,a.useState)(!1),[A,M]=(0,a.useState)(!1),[N,W]=(0,a.useState)(null),[$,_]=(0,a.useState)(null),O=(0,a.useRef)(s);O.current=s;const U=(0,a.useRef)(null);(0,a.useEffect)(()=>()=>{U.current&&clearTimeout(U.current)},[]);const j=(0,a.useCallback)(e=>({position:u,iframeMode:p,iconSize:b,magnification:h,magnificationScale:v,showNightModeButton:E,showExitButton:x,tourDismissed:C,apps:e}),[u,p,b,h,v,E,x,C]),L=(0,a.useCallback)(async()=>{U.current&&clearTimeout(U.current),M(!0),R("Saving…"),B(!1);try{const e=t(j(s));e&&"function"==typeof e.then&&await e,R("Configuration saved — plugin will restart"),B(!1),U.current=setTimeout(()=>R(""),5e3)}catch(e){R("Save failed: "+(e.message||e)),B(!0)}finally{M(!1)}},[s,j,t]),P=()=>_(null),K=()=>{W(null),_(null)},H=s.filter(e=>!1!==e.enabled);return a.createElement("div",{style:l.root},a.createElement("div",{style:l.sectionTitle},"Dock Settings"),a.createElement(i,{label:"Dock position",value:u,onChange:g,options:[{value:"bottom",label:"Bottom"},{value:"top",label:"Top"},{value:"left",label:"Left"},{value:"right",label:"Right"}]}),a.createElement(i,{label:"iFrame lifecycle",value:p,onChange:m,options:[{value:"keep-alive",label:"Keep-alive (faster switching, more RAM)"},{value:"destroy",label:"Destroy (reload each time, less RAM)"}]}),a.createElement(r,{label:"Icon size",value:b,onChange:f,hint:"px"}),a.createElement(c,{label:"Magnification effect",checked:h,onChange:y}),h&&a.createElement(r,{label:"Magnification scale",value:v,onChange:k,hint:"1.0 - 2.5"}),a.createElement("div",{style:l.sectionTitle},"Utility Buttons"),a.createElement(c,{label:"Night/Day mode toggle",checked:E,onChange:S,hint:"Sun/moon button to toggle environment.mode"}),a.createElement(c,{label:"Exit button",checked:x,onChange:w,hint:"X button to return to Signal K admin UI"}),a.createElement("div",{style:l.sectionTitle},"Webapps"),a.createElement("button",{style:{...l.btn,...l.btnPrimary,...T?{opacity:.6}:{}},onClick:async()=>{F(!0),R(""),B(!1);try{const e=await fetch("/skServer/webapps"),t=(await e.json()).filter(e=>"@signalk/app-dock"!==e.name).map(e=>"@signalk/server-admin-ui"===e.name?{name:e.name,label:"Settings",url:"/admin/",icon:"/@signalk/app-dock/icon-settings.svg",isAdmin:!0}:{name:e.name,label:e.signalk?.displayName||e.name,url:`/${e.name}/`,icon:e.signalk?.appIcon?`/${e.name}/${e.signalk.appIcon}`:null});if(0===t.length)return R("No webapps found. Is the server fully started?"),B(!0),void F(!1);const n=O.current,a=new Set(n.map(e=>e.url));let l=0;const o=[...n];for(const e of t)a.has(e.url)||(o.push({enabled:!0,autostart:!1,url:e.url,label:e.label||"",icon:e.icon||"",color:e.isAdmin?"#78788c":""}),l++);d(o),R(l>0?`Found ${t.length} webapps, added ${l} new.`:`All ${t.length} webapps already in list.`),B(!1)}catch(e){R("Discovery failed: "+e.message),B(!0)}F(!1)},disabled:T},T?"Discovering…":"Discover Installed Webapps"),z&&a.createElement("div",{style:{...l.status,color:I?"#ef4444":"#10b981"}},z),a.createElement("div",{style:l.sectionTitle},"Dock Apps (drag to reorder)"),0===s.length?a.createElement("div",{style:l.empty},"No apps yet. Click Discover above."):a.createElement("div",null,s.map((e,t)=>a.createElement("div",{key:e.url+t,draggable:!0,onDragStart:()=>(e=>W(e))(t),onDragOver:e=>((e,t)=>{e.preventDefault(),_(t)})(e,t),onDragLeave:P,onDrop:e=>((e,t)=>{if(e.preventDefault(),_(null),null!==N&&N!==t){const e=[...s],[n]=e.splice(N,1);e.splice(t,0,n),d(e)}W(null)})(e,t),onDragEnd:K,style:{...l.item,...!1===e.enabled?l.itemDisabled:{},...$===t?l.dragOver:{},...N===t?{opacity:.4}:{}}},a.createElement("span",{style:l.handle},"≡"),a.createElement(o,{icon:e.icon,label:e.label||e.url,color:e.color}),a.createElement("div",{style:l.info},a.createElement("div",{style:l.name},e.label||e.url),a.createElement("div",{style:l.url},e.url)),a.createElement("div",{style:l.toggle,title:"Enabled"},a.createElement("input",{type:"checkbox",checked:!1!==e.enabled,onChange:()=>(e=>d(s.map((t,n)=>n===e?{...t,enabled:!t.enabled}:t)))(t),style:l.checkbox})),a.createElement("div",{style:{...l.toggle,cursor:"pointer",fontSize:16,opacity:e.autostart?1:.25},onClick:()=>(e=>d(s.map((t,n)=>n===e?{...t,autostart:!t.autostart}:{...t,autostart:!1})))(t),title:e.autostart?"Autostart: ON (click to disable)":"Click to set as autostart app"},"▶"),a.createElement("button",{style:{...l.btn,...l.btnDanger},onClick:()=>(e=>d(s.filter((t,n)=>n!==e)))(t)},"Remove")))),(H.length>0||E||x)&&a.createElement(a.Fragment,null,a.createElement("div",{style:l.sectionTitle},"Preview"),a.createElement("div",{style:l.preview},E&&a.createElement(a.Fragment,null,a.createElement("div",{style:{...l.iconBox,width:40,height:40,background:"rgba(255,255,255,0.08)",border:"1px solid rgba(255,255,255,0.12)",fontSize:20,borderRadius:10}},"☀️"),a.createElement("div",{style:{width:1,height:24,background:"rgba(255,255,255,0.15)",alignSelf:"center"}})),H.map((e,t)=>a.createElement(o,{key:t,icon:e.icon,label:e.label||e.url,color:e.color,size:40})),x&&a.createElement(a.Fragment,null,a.createElement("div",{style:{width:1,height:24,background:"rgba(255,255,255,0.15)",alignSelf:"center"}}),a.createElement("div",{style:{...l.iconBox,width:40,height:40,background:"rgba(255,255,255,0.08)",border:"1px solid rgba(255,255,255,0.12)",fontSize:16,borderRadius:10,color:"#fff"}},"✕")))),a.createElement("div",{style:l.actions},a.createElement("button",{style:{...l.btn,...l.btnSave,...A?{opacity:.6}:{}},onClick:L,disabled:A},A?"Saving…":"Save Configuration"),z&&a.createElement("div",{style:{...l.status,color:I?"#ef4444":"#10b981"}},z)))}}}]);
|
package/public/app-icon.svg
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" width="72" height="72">
|
|
2
2
|
<defs>
|
|
3
3
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
-
<stop offset="0%" stop-color="#
|
|
5
|
-
<stop offset="100%" stop-color="#
|
|
4
|
+
<stop offset="0%" stop-color="#60a5fa"/>
|
|
5
|
+
<stop offset="100%" stop-color="#2563eb"/>
|
|
6
6
|
</linearGradient>
|
|
7
7
|
</defs>
|
|
8
|
+
<!-- White base -->
|
|
9
|
+
<rect width="72" height="72" fill="#ffffff"/>
|
|
8
10
|
<!-- Background -->
|
|
9
11
|
<rect width="72" height="72" rx="16" fill="url(#bg)"/>
|
|
10
12
|
<!-- Dock pill -->
|
|
11
|
-
<rect x="8" y="48" width="56" height="16" rx="8" fill="#ffffff" fill-opacity="0.
|
|
13
|
+
<rect x="8" y="48" width="56" height="16" rx="8" fill="#ffffff" fill-opacity="0.25" stroke="#ffffff" stroke-opacity="0.4" stroke-width="0.5"/>
|
|
12
14
|
<!-- Dock icons - 3 squares in the pill -->
|
|
13
15
|
<rect x="14" y="51" width="10" height="10" rx="2.5" fill="#3b82f6" opacity="0.9"/>
|
|
14
16
|
<rect x="31" y="51" width="10" height="10" rx="2.5" fill="#10b981" opacity="0.9"/>
|
package/public/dock.css
CHANGED
|
@@ -120,7 +120,7 @@ body {
|
|
|
120
120
|
/* ─── Dock shell ────────────────────────────────────────────────────────────── */
|
|
121
121
|
#dock {
|
|
122
122
|
position: fixed;
|
|
123
|
-
z-index:
|
|
123
|
+
z-index: 40;
|
|
124
124
|
opacity: 0;
|
|
125
125
|
pointer-events: none;
|
|
126
126
|
transition:
|
|
@@ -396,19 +396,6 @@ body {
|
|
|
396
396
|
background: rgba(239, 68, 68, 0.25);
|
|
397
397
|
}
|
|
398
398
|
|
|
399
|
-
/* ─── Invisible corner trigger zone ────────────────────────────────────────── */
|
|
400
|
-
#trigger-corner {
|
|
401
|
-
position: fixed;
|
|
402
|
-
z-index: 9;
|
|
403
|
-
width: 120px;
|
|
404
|
-
height: 120px;
|
|
405
|
-
pointer-events: auto;
|
|
406
|
-
touch-action: none;
|
|
407
|
-
-webkit-touch-callout: none;
|
|
408
|
-
-webkit-user-select: none;
|
|
409
|
-
user-select: none;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
399
|
/* ─── Loading overlay ───────────────────────────────────────────────────────── */
|
|
413
400
|
#loading-overlay {
|
|
414
401
|
position: fixed;
|
|
@@ -447,3 +434,115 @@ body {
|
|
|
447
434
|
transform: rotate(360deg);
|
|
448
435
|
}
|
|
449
436
|
}
|
|
437
|
+
|
|
438
|
+
/* ─── Welcome tour ─────────────────────────────────────────────────────────── */
|
|
439
|
+
#tour-overlay {
|
|
440
|
+
position: fixed;
|
|
441
|
+
inset: 0;
|
|
442
|
+
z-index: 30;
|
|
443
|
+
display: flex;
|
|
444
|
+
align-items: center;
|
|
445
|
+
justify-content: center;
|
|
446
|
+
background: rgba(0, 0, 0, 0.6);
|
|
447
|
+
backdrop-filter: blur(4px);
|
|
448
|
+
-webkit-backdrop-filter: blur(4px);
|
|
449
|
+
opacity: 0;
|
|
450
|
+
pointer-events: none;
|
|
451
|
+
transition: opacity 0.3s ease;
|
|
452
|
+
padding: 24px;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
#tour-overlay.visible {
|
|
456
|
+
opacity: 1;
|
|
457
|
+
pointer-events: auto;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
#tour-card {
|
|
461
|
+
max-width: 520px;
|
|
462
|
+
width: 100%;
|
|
463
|
+
background: rgba(30, 30, 32, 0.9);
|
|
464
|
+
backdrop-filter: blur(40px) saturate(180%);
|
|
465
|
+
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
|
466
|
+
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
467
|
+
border-radius: 22px;
|
|
468
|
+
padding: 32px;
|
|
469
|
+
color: rgba(255, 255, 255, 0.92);
|
|
470
|
+
box-shadow:
|
|
471
|
+
0 24px 64px rgba(0, 0, 0, 0.6),
|
|
472
|
+
inset 0 0.5px 0 rgba(255, 255, 255, 0.15);
|
|
473
|
+
/* Position above the dock so user can see both */
|
|
474
|
+
margin-bottom: 180px;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#tour-title {
|
|
478
|
+
font-size: 22px;
|
|
479
|
+
font-weight: 700;
|
|
480
|
+
letter-spacing: 0.01em;
|
|
481
|
+
margin-bottom: 16px;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
#tour-body {
|
|
485
|
+
font-size: 15px;
|
|
486
|
+
line-height: 1.55;
|
|
487
|
+
color: rgba(255, 255, 255, 0.78);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
#tour-body p {
|
|
491
|
+
margin-bottom: 12px;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#tour-body p:last-child {
|
|
495
|
+
margin-bottom: 0;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
#tour-link {
|
|
499
|
+
color: #60a5fa;
|
|
500
|
+
text-decoration: none;
|
|
501
|
+
font-weight: 600;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#tour-link:hover {
|
|
505
|
+
text-decoration: underline;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
#tour-buttons {
|
|
509
|
+
display: flex;
|
|
510
|
+
gap: 10px;
|
|
511
|
+
margin-top: 24px;
|
|
512
|
+
justify-content: flex-end;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.tour-btn {
|
|
516
|
+
padding: 10px 20px;
|
|
517
|
+
border: none;
|
|
518
|
+
border-radius: 10px;
|
|
519
|
+
font-size: 14px;
|
|
520
|
+
font-weight: 600;
|
|
521
|
+
cursor: pointer;
|
|
522
|
+
font-family: inherit;
|
|
523
|
+
transition:
|
|
524
|
+
background 0.15s ease,
|
|
525
|
+
transform 0.1s ease;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
.tour-btn-primary {
|
|
529
|
+
background: #3b82f6;
|
|
530
|
+
color: #fff;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.tour-btn-primary:hover {
|
|
534
|
+
background: #2563eb;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.tour-btn-dismiss {
|
|
538
|
+
background: rgba(255, 255, 255, 0.08);
|
|
539
|
+
color: rgba(255, 255, 255, 0.8);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
.tour-btn-dismiss:hover {
|
|
543
|
+
background: rgba(255, 255, 255, 0.15);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.tour-btn:active {
|
|
547
|
+
transform: scale(0.97);
|
|
548
|
+
}
|
package/public/dock.js
CHANGED
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
const DEFAULTS = {
|
|
5
5
|
position: 'bottom',
|
|
6
|
-
triggerCorner: 'bottom-right',
|
|
7
6
|
iframeMode: 'keep-alive',
|
|
8
7
|
iconSize: 56,
|
|
9
8
|
magnification: true,
|
|
10
9
|
magnificationScale: 1.7,
|
|
11
|
-
showNightModeButton:
|
|
10
|
+
showNightModeButton: true,
|
|
12
11
|
showExitButton: false,
|
|
13
12
|
apps: []
|
|
14
13
|
}
|
|
@@ -44,7 +43,6 @@
|
|
|
44
43
|
const $backdrop = document.getElementById('dock-backdrop')
|
|
45
44
|
const $dock = document.getElementById('dock')
|
|
46
45
|
const $dockInner = document.getElementById('dock-inner')
|
|
47
|
-
const $triggerCorner = document.getElementById('trigger-corner')
|
|
48
46
|
const $loadingOverlay = document.getElementById('loading-overlay')
|
|
49
47
|
const $loadingLabel = document.getElementById('loading-label')
|
|
50
48
|
const $idleHint = document.getElementById('idle-hint')
|
|
@@ -130,18 +128,8 @@
|
|
|
130
128
|
return
|
|
131
129
|
}
|
|
132
130
|
|
|
133
|
-
const cornerNames = {
|
|
134
|
-
'bottom-right': 'bottom-right',
|
|
135
|
-
'bottom-left': 'bottom-left',
|
|
136
|
-
'top-right': 'top-right',
|
|
137
|
-
'top-left': 'top-left'
|
|
138
|
-
}
|
|
139
|
-
const corner = cornerNames[cfg.triggerCorner] || 'bottom-right'
|
|
140
|
-
|
|
141
131
|
$idleHintText.innerHTML =
|
|
142
|
-
'Double-tap
|
|
143
|
-
corner +
|
|
144
|
-
' corner</strong> to open the dock' +
|
|
132
|
+
'Double-tap <strong>anywhere</strong> to open the dock' +
|
|
145
133
|
'<br><br><span style="font-size:12px;opacity:0.6">Configure in Admin UI \u2192 Plugin Config \u2192 App Dock</span>'
|
|
146
134
|
}
|
|
147
135
|
|
|
@@ -420,6 +408,7 @@
|
|
|
420
408
|
|
|
421
409
|
function hideDock() {
|
|
422
410
|
if (!dockVisible) return
|
|
411
|
+
if ($tourOverlay && $tourOverlay.classList.contains('visible')) return
|
|
423
412
|
dockVisible = false
|
|
424
413
|
$dock.classList.remove('visible')
|
|
425
414
|
$backdrop.classList.remove('visible')
|
|
@@ -478,82 +467,111 @@
|
|
|
478
467
|
hideDock()
|
|
479
468
|
}
|
|
480
469
|
|
|
481
|
-
// ───
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
470
|
+
// ─── Gesture: full-screen double-tap ────────────────────────────────────────
|
|
471
|
+
// No overlay element. We attach passive, non-preventDefault listeners to the
|
|
472
|
+
// parent document AND each same-origin iframe's contentDocument (recursively,
|
|
473
|
+
// to handle webapps like KIP that embed other apps), so webapps receive every
|
|
474
|
+
// tap/click normally. A double-tap anywhere opens the dock. Side effect:
|
|
475
|
+
// double-tapping a button fires that button once before the dock opens —
|
|
476
|
+
// users are expected to aim double-taps at non-interactive regions.
|
|
477
|
+
const DOUBLE_TAP_MS = 280
|
|
478
|
+
const DOUBLE_TAP_MIN_GAP_MS = 60
|
|
479
|
+
const DOUBLE_TAP_SLOP = 24
|
|
480
|
+
|
|
481
|
+
let lastTapTime = 0
|
|
482
|
+
let lastTapX = 0
|
|
483
|
+
let lastTapY = 0
|
|
484
|
+
|
|
485
|
+
function triggerDock() {
|
|
486
|
+
if (dockVisible) return
|
|
487
|
+
showDock()
|
|
488
|
+
if (navigator.vibrate) navigator.vibrate(8)
|
|
489
|
+
}
|
|
493
490
|
|
|
494
|
-
|
|
495
|
-
|
|
491
|
+
function handleTap(clientX, clientY) {
|
|
492
|
+
if (dockVisible) return
|
|
493
|
+
const now = Date.now()
|
|
494
|
+
const dx = clientX - lastTapX
|
|
495
|
+
const dy = clientY - lastTapY
|
|
496
|
+
const close = dx * dx + dy * dy <= DOUBLE_TAP_SLOP * DOUBLE_TAP_SLOP
|
|
497
|
+
const gap = now - lastTapTime
|
|
498
|
+
if (gap >= DOUBLE_TAP_MIN_GAP_MS && gap < DOUBLE_TAP_MS && close) {
|
|
499
|
+
lastTapTime = 0
|
|
500
|
+
triggerDock()
|
|
501
|
+
} else {
|
|
502
|
+
lastTapTime = now
|
|
503
|
+
lastTapX = clientX
|
|
504
|
+
lastTapY = clientY
|
|
505
|
+
}
|
|
496
506
|
}
|
|
497
507
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
const DOUBLE_TAP_MS = 400
|
|
508
|
+
function attachTapListener(doc, frameEl) {
|
|
509
|
+
if (!doc || doc.__dockListenersAttached) return
|
|
510
|
+
doc.__dockListenersAttached = true
|
|
502
511
|
|
|
503
|
-
|
|
504
|
-
|
|
512
|
+
const offset = () => {
|
|
513
|
+
if (!frameEl) return { x: 0, y: 0 }
|
|
514
|
+
const r = frameEl.getBoundingClientRect()
|
|
515
|
+
return { x: r.left, y: r.top }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
doc.addEventListener(
|
|
519
|
+
'pointerdown',
|
|
505
520
|
(e) => {
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
e.preventDefault()
|
|
509
|
-
lastTapTime = 0
|
|
510
|
-
showDock()
|
|
511
|
-
if (navigator.vibrate) navigator.vibrate(8)
|
|
512
|
-
} else {
|
|
513
|
-
lastTapTime = now
|
|
514
|
-
}
|
|
521
|
+
const o = offset()
|
|
522
|
+
handleTap(e.clientX + o.x, e.clientY + o.y)
|
|
515
523
|
},
|
|
516
|
-
{ passive:
|
|
524
|
+
{ passive: true, capture: true }
|
|
517
525
|
)
|
|
526
|
+
}
|
|
518
527
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
528
|
+
// Recursively attach to iframes at any nesting depth. Webapps like KIP
|
|
529
|
+
// embed other webapps (e.g. FSK) in their own iframes, so we walk the tree
|
|
530
|
+
// and observe each same-origin contentDocument for future iframe additions.
|
|
531
|
+
const attachedDocs = new WeakSet()
|
|
532
|
+
|
|
533
|
+
function attachToIframe(frame) {
|
|
534
|
+
const wire = () => {
|
|
535
|
+
let doc
|
|
536
|
+
try {
|
|
537
|
+
doc = frame.contentDocument
|
|
538
|
+
} catch {
|
|
539
|
+
return // cross-origin; can't reach
|
|
540
|
+
}
|
|
541
|
+
if (!doc || attachedDocs.has(doc)) return
|
|
542
|
+
attachedDocs.add(doc)
|
|
543
|
+
attachTapListener(doc, frame)
|
|
544
|
+
scanAndObserveDoc(doc)
|
|
545
|
+
}
|
|
546
|
+
frame.addEventListener('load', wire)
|
|
547
|
+
wire()
|
|
548
|
+
}
|
|
523
549
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
if (pos === 'top' && e.clientY <= EDGE_ZONE) atEdge = true
|
|
540
|
-
if (pos === 'left' && e.clientX <= EDGE_ZONE) atEdge = true
|
|
541
|
-
if (pos === 'right' && e.clientX >= W - EDGE_ZONE) atEdge = true
|
|
542
|
-
|
|
543
|
-
if (atEdge && !edgeTimer) {
|
|
544
|
-
edgeTimer = setTimeout(() => {
|
|
545
|
-
showDock()
|
|
546
|
-
edgeTimer = null
|
|
547
|
-
}, 300)
|
|
548
|
-
} else if (!atEdge && edgeTimer) {
|
|
549
|
-
clearTimeout(edgeTimer)
|
|
550
|
-
edgeTimer = null
|
|
550
|
+
function scanAndObserveDoc(doc) {
|
|
551
|
+
// Attach to iframes already in this document.
|
|
552
|
+
doc.querySelectorAll('iframe').forEach((f) => attachToIframe(f))
|
|
553
|
+
|
|
554
|
+
// Observe future iframe additions anywhere in the doc tree.
|
|
555
|
+
const root = doc.documentElement || doc.body || doc
|
|
556
|
+
const obs = new MutationObserver((records) => {
|
|
557
|
+
for (const r of records) {
|
|
558
|
+
r.addedNodes.forEach((n) => {
|
|
559
|
+
if (!n || n.nodeType !== 1) return
|
|
560
|
+
if (n.tagName === 'IFRAME') attachToIframe(n)
|
|
561
|
+
// Descendants: catch iframes added as part of a subtree.
|
|
562
|
+
const nested = n.querySelectorAll && n.querySelectorAll('iframe')
|
|
563
|
+
if (nested) nested.forEach((f) => attachToIframe(f))
|
|
564
|
+
})
|
|
551
565
|
}
|
|
552
566
|
})
|
|
567
|
+
obs.observe(root, { childList: true, subtree: true })
|
|
553
568
|
}
|
|
554
569
|
|
|
570
|
+
attachTapListener(document, null)
|
|
571
|
+
attachedDocs.add(document)
|
|
572
|
+
scanAndObserveDoc(document)
|
|
573
|
+
|
|
555
574
|
// ─── Init ────────────────────────────────────────────────────────────────────
|
|
556
|
-
positionCornerZone()
|
|
557
575
|
buildDock()
|
|
558
576
|
|
|
559
577
|
if (cfg.showNightModeButton) {
|
|
@@ -561,6 +579,104 @@
|
|
|
561
579
|
setInterval(fetchCurrentMode, 5000)
|
|
562
580
|
}
|
|
563
581
|
|
|
582
|
+
// Changes to any of these require a full rebuild (dock classes, magnification
|
|
583
|
+
// constants, dock items, etc.) — easiest to just reload the page.
|
|
584
|
+
const STRUCTURAL_KEYS = [
|
|
585
|
+
'position',
|
|
586
|
+
'iframeMode',
|
|
587
|
+
'iconSize',
|
|
588
|
+
'magnification',
|
|
589
|
+
'magnificationScale',
|
|
590
|
+
'showNightModeButton',
|
|
591
|
+
'showExitButton'
|
|
592
|
+
]
|
|
593
|
+
|
|
594
|
+
async function refreshConfig() {
|
|
595
|
+
try {
|
|
596
|
+
const [configRes, settingsRes] = await Promise.all([
|
|
597
|
+
fetch('/plugins/signalk-app-dock/config'),
|
|
598
|
+
fetch('/plugins/signalk-app-dock/settings')
|
|
599
|
+
])
|
|
600
|
+
const prev = cfg
|
|
601
|
+
let next = cfg
|
|
602
|
+
if (configRes.ok) {
|
|
603
|
+
const data = await configRes.json()
|
|
604
|
+
const pluginCfg = data.configuration || data
|
|
605
|
+
next = { ...DEFAULTS, ...pluginCfg, apps: prev.apps }
|
|
606
|
+
}
|
|
607
|
+
if (settingsRes.ok) {
|
|
608
|
+
const data = await settingsRes.json()
|
|
609
|
+
if (Array.isArray(data.apps) && data.apps.length > 0) {
|
|
610
|
+
next = { ...next, apps: data.apps }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const structuralChanged = STRUCTURAL_KEYS.some((k) => prev[k] !== next[k])
|
|
614
|
+
if (structuralChanged) {
|
|
615
|
+
window.location.reload()
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
if (JSON.stringify(next.apps) !== JSON.stringify(prev.apps)) {
|
|
619
|
+
cfg = next
|
|
620
|
+
buildDock()
|
|
621
|
+
} else {
|
|
622
|
+
cfg = next
|
|
623
|
+
}
|
|
624
|
+
} catch {
|
|
625
|
+
// ignore transient errors (e.g. during plugin restart)
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
setInterval(refreshConfig, 5000)
|
|
630
|
+
|
|
631
|
+
// ─── Welcome tour ────────────────────────────────────────────────────────────
|
|
632
|
+
const $tourOverlay = document.getElementById('tour-overlay')
|
|
633
|
+
const $tourGotIt = document.getElementById('tour-btn-got-it')
|
|
634
|
+
const $tourDismiss = document.getElementById('tour-btn-dismiss')
|
|
635
|
+
|
|
636
|
+
function hideTour() {
|
|
637
|
+
$tourOverlay.classList.remove('visible')
|
|
638
|
+
hideDock()
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function showTour() {
|
|
642
|
+
$tourOverlay.classList.add('visible')
|
|
643
|
+
showDock()
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if ($tourGotIt) $tourGotIt.addEventListener('click', hideTour)
|
|
647
|
+
if ($tourDismiss) {
|
|
648
|
+
$tourDismiss.addEventListener('click', async () => {
|
|
649
|
+
hideTour()
|
|
650
|
+
try {
|
|
651
|
+
await fetch('/plugins/signalk-app-dock/dismiss-tour', { method: 'POST' })
|
|
652
|
+
} catch {
|
|
653
|
+
// ignore network errors; worst case user sees the tour again next reload
|
|
654
|
+
}
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const $tourLink = document.getElementById('tour-link')
|
|
659
|
+
if ($tourLink) {
|
|
660
|
+
$tourLink.addEventListener('click', (e) => {
|
|
661
|
+
e.preventDefault()
|
|
662
|
+
const adminUrl = '/admin/#/serverConfiguration/plugins/signalk-app-dock'
|
|
663
|
+
hideIdleHint()
|
|
664
|
+
const $frame = document.createElement('iframe')
|
|
665
|
+
$frame.allow = 'fullscreen; geolocation'
|
|
666
|
+
$frame.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-pointer-lock'
|
|
667
|
+
$frame.style.touchAction = 'auto'
|
|
668
|
+
$frame.classList.add('active')
|
|
669
|
+
$iframeContainer.innerHTML = ''
|
|
670
|
+
Object.keys(iframes).forEach((k) => delete iframes[k])
|
|
671
|
+
$iframeContainer.appendChild($frame)
|
|
672
|
+
$frame.src = adminUrl
|
|
673
|
+
iframes[adminUrl] = $frame
|
|
674
|
+
hideTour()
|
|
675
|
+
})
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (!cfg.tourDismissed) showTour()
|
|
679
|
+
|
|
564
680
|
const autostartIdx = cfg.apps.findIndex((a) => a.autostart)
|
|
565
681
|
if (autostartIdx >= 0) {
|
|
566
682
|
switchToApp(autostartIdx)
|
package/public/index.html
CHANGED
|
@@ -27,15 +27,34 @@
|
|
|
27
27
|
<div id="dock-inner"></div>
|
|
28
28
|
</nav>
|
|
29
29
|
|
|
30
|
-
<!-- Invisible corner hot-zone for long-press trigger -->
|
|
31
|
-
<div id="trigger-corner" aria-hidden="true"></div>
|
|
32
|
-
|
|
33
30
|
<!-- Loading indicator shown while an iframe is loading for the first time -->
|
|
34
31
|
<div id="loading-overlay" aria-hidden="true">
|
|
35
32
|
<div class="spinner"></div>
|
|
36
33
|
<span id="loading-label"></span>
|
|
37
34
|
</div>
|
|
38
35
|
|
|
36
|
+
<!-- First-run welcome tour -->
|
|
37
|
+
<div id="tour-overlay" aria-hidden="true">
|
|
38
|
+
<div id="tour-card" role="dialog" aria-labelledby="tour-title">
|
|
39
|
+
<div id="tour-title">Welcome to App Dock</div>
|
|
40
|
+
<div id="tour-body">
|
|
41
|
+
<p>App Dock gives you a macOS-style dock to quickly switch between Signal K webapps on touch screens.</p>
|
|
42
|
+
<p>
|
|
43
|
+
<strong>Double-tap anywhere</strong> on the screen to open the dock, then tap an icon to launch that app.
|
|
44
|
+
</p>
|
|
45
|
+
<p>
|
|
46
|
+
Freeboard-SK is loading in the background and KIP is one tap away. Customize the apps, dock position, and
|
|
47
|
+
more in
|
|
48
|
+
<a id="tour-link" href="#">Plugin Config</a>.
|
|
49
|
+
</p>
|
|
50
|
+
</div>
|
|
51
|
+
<div id="tour-buttons">
|
|
52
|
+
<button id="tour-btn-got-it" class="tour-btn tour-btn-primary">Got it</button>
|
|
53
|
+
<button id="tour-btn-dismiss" class="tour-btn tour-btn-dismiss">Don't show again</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
39
58
|
<script src="dock.js"></script>
|
|
40
59
|
</body>
|
|
41
60
|
</html>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useRef, useCallback } from 'react'
|
|
1
|
+
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
|
2
2
|
|
|
3
3
|
const S = {
|
|
4
4
|
root: {
|
|
@@ -175,51 +175,74 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
175
175
|
const cfg = configuration || {}
|
|
176
176
|
const [apps, setApps] = useState(() => cfg.apps || [])
|
|
177
177
|
const [position, setPosition] = useState(cfg.position || 'bottom')
|
|
178
|
-
const [triggerCorner, setTriggerCorner] = useState(cfg.triggerCorner || 'bottom-right')
|
|
179
178
|
const [iframeMode, setIframeMode] = useState(cfg.iframeMode || 'keep-alive')
|
|
180
179
|
const [iconSize, setIconSize] = useState(cfg.iconSize || 56)
|
|
181
180
|
const [magnification, setMagnification] = useState(cfg.magnification !== false)
|
|
182
181
|
const [magnificationScale, setMagnificationScale] = useState(cfg.magnificationScale || 1.7)
|
|
183
182
|
const [showNightModeButton, setShowNightModeButton] = useState(cfg.showNightModeButton || false)
|
|
184
183
|
const [showExitButton, setShowExitButton] = useState(cfg.showExitButton || false)
|
|
184
|
+
const [tourDismissed, setTourDismissed] = useState(cfg.tourDismissed || false)
|
|
185
185
|
|
|
186
186
|
const [status, setStatus] = useState('')
|
|
187
187
|
const [statusError, setStatusError] = useState(false)
|
|
188
188
|
const [discovering, setDiscovering] = useState(false)
|
|
189
|
+
const [saving, setSaving] = useState(false)
|
|
189
190
|
const [dragIdx, setDragIdx] = useState(null)
|
|
190
191
|
const [overIdx, setOverIdx] = useState(null)
|
|
191
192
|
|
|
192
193
|
const appsRef = useRef(apps)
|
|
193
194
|
appsRef.current = apps
|
|
194
195
|
|
|
196
|
+
const statusTimeoutRef = useRef(null)
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
return () => {
|
|
199
|
+
if (statusTimeoutRef.current) clearTimeout(statusTimeoutRef.current)
|
|
200
|
+
}
|
|
201
|
+
}, [])
|
|
202
|
+
|
|
195
203
|
const buildConfig = useCallback(
|
|
196
204
|
(appsList) => ({
|
|
197
205
|
position,
|
|
198
|
-
triggerCorner,
|
|
199
206
|
iframeMode,
|
|
200
207
|
iconSize,
|
|
201
208
|
magnification,
|
|
202
209
|
magnificationScale,
|
|
203
210
|
showNightModeButton,
|
|
204
211
|
showExitButton,
|
|
212
|
+
tourDismissed,
|
|
205
213
|
apps: appsList
|
|
206
214
|
}),
|
|
207
215
|
[
|
|
208
216
|
position,
|
|
209
|
-
triggerCorner,
|
|
210
217
|
iframeMode,
|
|
211
218
|
iconSize,
|
|
212
219
|
magnification,
|
|
213
220
|
magnificationScale,
|
|
214
221
|
showNightModeButton,
|
|
215
|
-
showExitButton
|
|
222
|
+
showExitButton,
|
|
223
|
+
tourDismissed
|
|
216
224
|
]
|
|
217
225
|
)
|
|
218
226
|
|
|
219
|
-
const doSave = useCallback(() => {
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
const doSave = useCallback(async () => {
|
|
228
|
+
if (statusTimeoutRef.current) clearTimeout(statusTimeoutRef.current)
|
|
229
|
+
setSaving(true)
|
|
230
|
+
setStatus('Saving\u2026')
|
|
222
231
|
setStatusError(false)
|
|
232
|
+
try {
|
|
233
|
+
const result = save(buildConfig(apps))
|
|
234
|
+
if (result && typeof result.then === 'function') {
|
|
235
|
+
await result
|
|
236
|
+
}
|
|
237
|
+
setStatus('Configuration saved \u2014 plugin will restart')
|
|
238
|
+
setStatusError(false)
|
|
239
|
+
statusTimeoutRef.current = setTimeout(() => setStatus(''), 5000)
|
|
240
|
+
} catch (e) {
|
|
241
|
+
setStatus('Save failed: ' + (e.message || e))
|
|
242
|
+
setStatusError(true)
|
|
243
|
+
} finally {
|
|
244
|
+
setSaving(false)
|
|
245
|
+
}
|
|
223
246
|
}, [apps, buildConfig, save])
|
|
224
247
|
|
|
225
248
|
const discover = async () => {
|
|
@@ -333,18 +356,6 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
333
356
|
]}
|
|
334
357
|
/>
|
|
335
358
|
|
|
336
|
-
<SelectField
|
|
337
|
-
label="Double-tap corner"
|
|
338
|
-
value={triggerCorner}
|
|
339
|
-
onChange={setTriggerCorner}
|
|
340
|
-
options={[
|
|
341
|
-
{ value: 'bottom-right', label: 'Bottom-right' },
|
|
342
|
-
{ value: 'bottom-left', label: 'Bottom-left' },
|
|
343
|
-
{ value: 'top-right', label: 'Top-right' },
|
|
344
|
-
{ value: 'top-left', label: 'Top-left' }
|
|
345
|
-
]}
|
|
346
|
-
/>
|
|
347
|
-
|
|
348
359
|
<SelectField
|
|
349
360
|
label="iFrame lifecycle"
|
|
350
361
|
value={iframeMode}
|
|
@@ -498,9 +509,14 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
498
509
|
)}
|
|
499
510
|
|
|
500
511
|
<div style={S.actions}>
|
|
501
|
-
<button
|
|
502
|
-
|
|
512
|
+
<button
|
|
513
|
+
style={{ ...S.btn, ...S.btnSave, ...(saving ? { opacity: 0.6 } : {}) }}
|
|
514
|
+
onClick={doSave}
|
|
515
|
+
disabled={saving}
|
|
516
|
+
>
|
|
517
|
+
{saving ? 'Saving\u2026' : 'Save Configuration'}
|
|
503
518
|
</button>
|
|
519
|
+
{status && <div style={{ ...S.status, color: statusError ? '#ef4444' : '#10b981' }}>{status}</div>}
|
|
504
520
|
</div>
|
|
505
521
|
</div>
|
|
506
522
|
)
|
package/test/plugin.test.js
CHANGED
|
@@ -58,12 +58,13 @@ describe('schema', () => {
|
|
|
58
58
|
const plugin = pluginFactory(createMockApp())
|
|
59
59
|
const props = Object.keys(plugin.schema.properties)
|
|
60
60
|
assert.ok(props.includes('position'))
|
|
61
|
-
assert.ok(props.includes('triggerCorner'))
|
|
62
61
|
assert.ok(props.includes('iframeMode'))
|
|
63
62
|
assert.ok(props.includes('iconSize'))
|
|
64
63
|
assert.ok(props.includes('magnification'))
|
|
65
64
|
assert.ok(props.includes('magnificationScale'))
|
|
66
65
|
assert.ok(props.includes('apps'))
|
|
66
|
+
assert.ok(!props.includes('triggerCorner'), 'triggerCorner should be removed')
|
|
67
|
+
assert.ok(!props.includes('longPressActivation'), 'longPressActivation should be removed')
|
|
67
68
|
})
|
|
68
69
|
|
|
69
70
|
it('position enum contains all four edges', () => {
|
|
@@ -78,11 +79,11 @@ describe('schema', () => {
|
|
|
78
79
|
assert.deepEqual(items.required, ['url'])
|
|
79
80
|
})
|
|
80
81
|
|
|
81
|
-
it('defines showNightModeButton with default
|
|
82
|
+
it('defines showNightModeButton with default true', () => {
|
|
82
83
|
const plugin = pluginFactory(createMockApp())
|
|
83
84
|
const prop = plugin.schema.properties.showNightModeButton
|
|
84
85
|
assert.equal(prop.type, 'boolean')
|
|
85
|
-
assert.equal(prop.default,
|
|
86
|
+
assert.equal(prop.default, true)
|
|
86
87
|
})
|
|
87
88
|
|
|
88
89
|
it('defines showExitButton with default false', () => {
|
|
@@ -91,6 +92,13 @@ describe('schema', () => {
|
|
|
91
92
|
assert.equal(prop.type, 'boolean')
|
|
92
93
|
assert.equal(prop.default, false)
|
|
93
94
|
})
|
|
95
|
+
|
|
96
|
+
it('defines tourDismissed with default false', () => {
|
|
97
|
+
const plugin = pluginFactory(createMockApp())
|
|
98
|
+
const prop = plugin.schema.properties.tourDismissed
|
|
99
|
+
assert.equal(prop.type, 'boolean')
|
|
100
|
+
assert.equal(prop.default, false)
|
|
101
|
+
})
|
|
94
102
|
})
|
|
95
103
|
|
|
96
104
|
describe('webapp discovery', () => {
|
|
@@ -101,7 +109,8 @@ describe('webapp discovery', () => {
|
|
|
101
109
|
const router = {
|
|
102
110
|
get: (path, handler) => {
|
|
103
111
|
routes[path] = handler
|
|
104
|
-
}
|
|
112
|
+
},
|
|
113
|
+
post: () => {}
|
|
105
114
|
}
|
|
106
115
|
plugin.registerWithRouter(router)
|
|
107
116
|
|
|
@@ -123,7 +132,8 @@ describe('webapp discovery', () => {
|
|
|
123
132
|
const router = {
|
|
124
133
|
get: (path, handler) => {
|
|
125
134
|
routes[path] = handler
|
|
126
|
-
}
|
|
135
|
+
},
|
|
136
|
+
post: () => {}
|
|
127
137
|
}
|
|
128
138
|
plugin.registerWithRouter(router)
|
|
129
139
|
|
|
@@ -146,7 +156,8 @@ describe('webapp discovery', () => {
|
|
|
146
156
|
const router = {
|
|
147
157
|
get: (path, handler) => {
|
|
148
158
|
routes[path] = handler
|
|
149
|
-
}
|
|
159
|
+
},
|
|
160
|
+
post: () => {}
|
|
150
161
|
}
|
|
151
162
|
plugin.registerWithRouter(router)
|
|
152
163
|
|
|
@@ -169,7 +180,8 @@ describe('webapp discovery', () => {
|
|
|
169
180
|
const router = {
|
|
170
181
|
get: (path, handler) => {
|
|
171
182
|
routes[path] = handler
|
|
172
|
-
}
|
|
183
|
+
},
|
|
184
|
+
post: () => {}
|
|
173
185
|
}
|
|
174
186
|
plugin.registerWithRouter(router)
|
|
175
187
|
|
|
@@ -193,7 +205,8 @@ describe('settings endpoint', () => {
|
|
|
193
205
|
const router = {
|
|
194
206
|
get: (path, handler) => {
|
|
195
207
|
routes[path] = handler
|
|
196
|
-
}
|
|
208
|
+
},
|
|
209
|
+
post: () => {}
|
|
197
210
|
}
|
|
198
211
|
plugin.registerWithRouter(router)
|
|
199
212
|
|
|
@@ -209,9 +222,9 @@ describe('settings endpoint', () => {
|
|
|
209
222
|
})
|
|
210
223
|
})
|
|
211
224
|
|
|
212
|
-
describe('
|
|
213
|
-
it('
|
|
214
|
-
const app = createMockApp(
|
|
225
|
+
describe('default apps seeding', () => {
|
|
226
|
+
it('seeds default apps when config has no apps key', () => {
|
|
227
|
+
const app = createMockApp()
|
|
215
228
|
let savedOptions = null
|
|
216
229
|
app.savePluginOptions = (opts, cb) => {
|
|
217
230
|
savedOptions = opts
|
|
@@ -219,73 +232,46 @@ describe('auto-discovery on start', () => {
|
|
|
219
232
|
}
|
|
220
233
|
|
|
221
234
|
const plugin = pluginFactory(app)
|
|
222
|
-
|
|
223
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
224
|
-
globalThis.setTimeout = (fn) => fn()
|
|
225
|
-
try {
|
|
226
|
-
plugin.start({ apps: [] })
|
|
227
|
-
} finally {
|
|
228
|
-
globalThis.setTimeout = originalSetTimeout
|
|
229
|
-
}
|
|
235
|
+
plugin.start({})
|
|
230
236
|
|
|
231
237
|
assert.ok(savedOptions)
|
|
232
|
-
assert.
|
|
233
|
-
assert.
|
|
234
|
-
assert.
|
|
235
|
-
|
|
238
|
+
assert.equal(savedOptions.apps[0].url, '/@signalk/freeboard-sk/')
|
|
239
|
+
assert.equal(savedOptions.apps[0].autostart, true)
|
|
240
|
+
assert.equal(savedOptions.apps[1].url, '/@mxtommy/kip/')
|
|
241
|
+
assert.equal(savedOptions.apps[1].autostart, false)
|
|
242
|
+
assert.equal(savedOptions.apps[2].url, '/admin/')
|
|
243
|
+
assert.equal(savedOptions.apps[2].label, 'Settings')
|
|
236
244
|
})
|
|
237
245
|
|
|
238
|
-
it('does not
|
|
239
|
-
const app = createMockApp(
|
|
240
|
-
let
|
|
241
|
-
app.savePluginOptions = (
|
|
242
|
-
|
|
243
|
-
cb(null)
|
|
246
|
+
it('does not seed when apps array already exists (even if empty)', () => {
|
|
247
|
+
const app = createMockApp()
|
|
248
|
+
let saveCalled = false
|
|
249
|
+
app.savePluginOptions = () => {
|
|
250
|
+
saveCalled = true
|
|
244
251
|
}
|
|
245
252
|
|
|
246
253
|
const plugin = pluginFactory(app)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
250
|
-
globalThis.setTimeout = (fn) => fn()
|
|
251
|
-
try {
|
|
252
|
-
plugin.start({ apps: existing })
|
|
253
|
-
} finally {
|
|
254
|
-
globalThis.setTimeout = originalSetTimeout
|
|
255
|
-
}
|
|
254
|
+
plugin.start({ apps: [] })
|
|
256
255
|
|
|
257
|
-
|
|
258
|
-
assert.equal(fbEntries.length, 1)
|
|
259
|
-
assert.equal(fbEntries[0].label, 'FB')
|
|
260
|
-
done()
|
|
256
|
+
assert.equal(saveCalled, false)
|
|
261
257
|
})
|
|
262
258
|
|
|
263
|
-
it('
|
|
264
|
-
const app = createMockApp(
|
|
259
|
+
it('preserves existing configured apps', () => {
|
|
260
|
+
const app = createMockApp()
|
|
265
261
|
let saveCalled = false
|
|
266
262
|
app.savePluginOptions = () => {
|
|
267
263
|
saveCalled = true
|
|
268
264
|
}
|
|
269
265
|
|
|
270
266
|
const plugin = pluginFactory(app)
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
|
|
274
|
-
]
|
|
275
|
-
|
|
276
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
277
|
-
globalThis.setTimeout = (fn) => fn()
|
|
278
|
-
try {
|
|
279
|
-
plugin.start({ apps: allConfigured })
|
|
280
|
-
} finally {
|
|
281
|
-
globalThis.setTimeout = originalSetTimeout
|
|
282
|
-
}
|
|
267
|
+
const existing = [{ enabled: true, url: '/custom-app/', label: 'Custom', icon: '', color: '' }]
|
|
268
|
+
plugin.start({ apps: existing })
|
|
283
269
|
|
|
284
270
|
assert.equal(saveCalled, false)
|
|
285
271
|
})
|
|
286
272
|
|
|
287
273
|
it('respects enabled:false when resolving apps', () => {
|
|
288
|
-
const app = createMockApp(
|
|
274
|
+
const app = createMockApp()
|
|
289
275
|
app.savePluginOptions = (_, cb) => cb(null)
|
|
290
276
|
const plugin = pluginFactory(app)
|
|
291
277
|
|
|
@@ -293,20 +279,14 @@ describe('auto-discovery on start', () => {
|
|
|
293
279
|
{ enabled: false, url: '/@signalk/freeboard-sk/', label: '', icon: '', color: '' },
|
|
294
280
|
{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
|
|
295
281
|
]
|
|
296
|
-
|
|
297
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
298
|
-
globalThis.setTimeout = (fn) => fn()
|
|
299
|
-
try {
|
|
300
|
-
plugin.start({ apps: configured })
|
|
301
|
-
} finally {
|
|
302
|
-
globalThis.setTimeout = originalSetTimeout
|
|
303
|
-
}
|
|
282
|
+
plugin.start({ apps: configured })
|
|
304
283
|
|
|
305
284
|
const routes = {}
|
|
306
285
|
const router = {
|
|
307
286
|
get: (path, handler) => {
|
|
308
287
|
routes[path] = handler
|
|
309
|
-
}
|
|
288
|
+
},
|
|
289
|
+
post: () => {}
|
|
310
290
|
}
|
|
311
291
|
plugin.registerWithRouter(router)
|
|
312
292
|
|
|
@@ -322,43 +302,6 @@ describe('auto-discovery on start', () => {
|
|
|
322
302
|
assert.ok(!resolved.apps.some((a) => a.url === '/@signalk/freeboard-sk/'))
|
|
323
303
|
assert.ok(resolved.apps.some((a) => a.url === '/@mxtommy/kip/'))
|
|
324
304
|
})
|
|
325
|
-
|
|
326
|
-
it('uses discovered label/icon when config overrides are empty', () => {
|
|
327
|
-
const app = createMockApp(MOCK_WEBAPPS)
|
|
328
|
-
app.savePluginOptions = (_, cb) => cb(null)
|
|
329
|
-
const plugin = pluginFactory(app)
|
|
330
|
-
|
|
331
|
-
const configured = [{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }]
|
|
332
|
-
|
|
333
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
334
|
-
globalThis.setTimeout = (fn) => fn()
|
|
335
|
-
try {
|
|
336
|
-
plugin.start({ apps: configured })
|
|
337
|
-
} finally {
|
|
338
|
-
globalThis.setTimeout = originalSetTimeout
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const routes = {}
|
|
342
|
-
const router = {
|
|
343
|
-
get: (path, handler) => {
|
|
344
|
-
routes[path] = handler
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
plugin.registerWithRouter(router)
|
|
348
|
-
|
|
349
|
-
let resolved
|
|
350
|
-
routes['/settings'](
|
|
351
|
-
{},
|
|
352
|
-
{
|
|
353
|
-
json: (d) => {
|
|
354
|
-
resolved = d
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
)
|
|
358
|
-
const kip = resolved.apps.find((a) => a.url === '/@mxtommy/kip/')
|
|
359
|
-
assert.equal(kip.label, 'KIP Instrument MFD')
|
|
360
|
-
assert.equal(kip.icon, '/@mxtommy/kip/assets/icon-72x72.png')
|
|
361
|
-
})
|
|
362
305
|
})
|
|
363
306
|
|
|
364
307
|
describe('package.json', () => {
|
|
@@ -470,6 +413,40 @@ describe('night mode PUT handler', () => {
|
|
|
470
413
|
})
|
|
471
414
|
})
|
|
472
415
|
|
|
416
|
+
describe('dismiss-tour endpoint', () => {
|
|
417
|
+
it('saves tourDismissed: true and responds', (t, done) => {
|
|
418
|
+
const app = createMockApp()
|
|
419
|
+
let saved = null
|
|
420
|
+
app.savePluginOptions = (opts, cb) => {
|
|
421
|
+
saved = opts
|
|
422
|
+
cb(null)
|
|
423
|
+
}
|
|
424
|
+
const plugin = pluginFactory(app)
|
|
425
|
+
plugin.start({ apps: [], position: 'left' })
|
|
426
|
+
|
|
427
|
+
const routes = { get: {}, post: {} }
|
|
428
|
+
const router = {
|
|
429
|
+
get: (path, handler) => {
|
|
430
|
+
routes.get[path] = handler
|
|
431
|
+
},
|
|
432
|
+
post: (path, handler) => {
|
|
433
|
+
routes.post[path] = handler
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
plugin.registerWithRouter(router)
|
|
437
|
+
|
|
438
|
+
const res = {
|
|
439
|
+
json: (data) => {
|
|
440
|
+
assert.equal(data.tourDismissed, true)
|
|
441
|
+
assert.equal(saved.tourDismissed, true)
|
|
442
|
+
assert.equal(saved.position, 'left')
|
|
443
|
+
done()
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
routes.post['/dismiss-tour']({}, res)
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
|
|
473
450
|
describe('mode endpoint', () => {
|
|
474
451
|
it('returns day when no mode is set', (t, done) => {
|
|
475
452
|
const app = createMockApp()
|
|
@@ -478,7 +455,8 @@ describe('mode endpoint', () => {
|
|
|
478
455
|
const router = {
|
|
479
456
|
get: (path, handler) => {
|
|
480
457
|
routes[path] = handler
|
|
481
|
-
}
|
|
458
|
+
},
|
|
459
|
+
post: () => {}
|
|
482
460
|
}
|
|
483
461
|
plugin.registerWithRouter(router)
|
|
484
462
|
|
|
@@ -502,7 +480,8 @@ describe('mode endpoint', () => {
|
|
|
502
480
|
const router = {
|
|
503
481
|
get: (path, handler) => {
|
|
504
482
|
routes[path] = handler
|
|
505
|
-
}
|
|
483
|
+
},
|
|
484
|
+
post: () => {}
|
|
506
485
|
}
|
|
507
486
|
plugin.registerWithRouter(router)
|
|
508
487
|
|