@signalk/app-dock 0.1.7 → 0.2.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/eslint.config.js +1 -0
- package/package.json +1 -1
- package/plugin/index.js +108 -40
- package/public/805.js +1 -1
- package/public/app-icon.svg +5 -3
- package/public/dock.css +150 -1
- package/public/dock.js +192 -0
- package/public/index.html +23 -0
- package/src/configpanel/PluginConfigurationPanel.js +102 -8
- package/test/plugin.test.js +230 -96
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,51 +47,52 @@ module.exports = (app) => {
|
|
|
20
47
|
name: 'App Dock',
|
|
21
48
|
|
|
22
49
|
start(settings) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const configuredUrls = new Set(configured.map((a) => a.url))
|
|
31
|
-
|
|
32
|
-
const merged = [...configured]
|
|
33
|
-
let added = false
|
|
34
|
-
for (const w of discovered) {
|
|
35
|
-
if (!configuredUrls.has(w.url)) {
|
|
36
|
-
merged.push({ enabled: true, url: w.url, label: '', icon: '', color: '' })
|
|
37
|
-
added = true
|
|
38
|
-
}
|
|
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
|
|
39
57
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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')
|
|
44
62
|
})
|
|
63
|
+
settings = seededSettings
|
|
64
|
+
}
|
|
45
65
|
|
|
46
|
-
|
|
47
|
-
.filter((a) => a.enabled !== false)
|
|
48
|
-
.map((a) => {
|
|
49
|
-
const match = discoveredByUrl[a.url]
|
|
50
|
-
return {
|
|
51
|
-
label: a.label || (match && match.label) || a.url,
|
|
52
|
-
url: a.url,
|
|
53
|
-
icon: a.icon || (match && match.icon) || null,
|
|
54
|
-
color: a.color || null,
|
|
55
|
-
autostart: a.autostart || false
|
|
56
|
-
}
|
|
57
|
-
})
|
|
66
|
+
pluginSettings = settings
|
|
58
67
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
if (settings.showNightModeButton) {
|
|
69
|
+
app.registerPutHandler(
|
|
70
|
+
'vessels.self',
|
|
71
|
+
'environment.mode',
|
|
72
|
+
(context, path, value, _callback) => {
|
|
73
|
+
if (value !== 'day' && value !== 'night') {
|
|
74
|
+
return { state: 'COMPLETED', statusCode: 400, message: 'Invalid mode' }
|
|
75
|
+
}
|
|
76
|
+
app.handleMessage(plugin.id, {
|
|
77
|
+
updates: [{ values: [{ path: 'environment.mode', value }] }]
|
|
78
|
+
})
|
|
79
|
+
return { state: 'COMPLETED', statusCode: 200 }
|
|
80
|
+
},
|
|
81
|
+
plugin.id
|
|
82
|
+
)
|
|
83
|
+
}
|
|
65
84
|
|
|
66
|
-
|
|
67
|
-
|
|
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(', '))
|
|
68
96
|
},
|
|
69
97
|
|
|
70
98
|
stop() {},
|
|
@@ -80,6 +108,24 @@ module.exports = (app) => {
|
|
|
80
108
|
router.get('/webapps', (req, res) => {
|
|
81
109
|
res.json(getWebapps())
|
|
82
110
|
})
|
|
111
|
+
|
|
112
|
+
router.get('/mode', (req, res) => {
|
|
113
|
+
const current = app.getSelfPath('environment.mode')
|
|
114
|
+
res.json({ value: (current && current.value) || 'day' })
|
|
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
|
+
})
|
|
83
129
|
},
|
|
84
130
|
|
|
85
131
|
schema: {
|
|
@@ -131,6 +177,28 @@ module.exports = (app) => {
|
|
|
131
177
|
default: 1.7
|
|
132
178
|
},
|
|
133
179
|
|
|
180
|
+
showNightModeButton: {
|
|
181
|
+
type: 'boolean',
|
|
182
|
+
title: 'Show night/day mode toggle',
|
|
183
|
+
description: 'Adds a sun/moon button to the dock that toggles environment.mode',
|
|
184
|
+
default: true
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
showExitButton: {
|
|
188
|
+
type: 'boolean',
|
|
189
|
+
title: 'Show exit button',
|
|
190
|
+
description: 'Adds an X button to the dock that returns to Signal K admin UI',
|
|
191
|
+
default: false
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
tourDismissed: {
|
|
195
|
+
type: 'boolean',
|
|
196
|
+
title: 'Welcome tour dismissed',
|
|
197
|
+
description:
|
|
198
|
+
'Set automatically when a user clicks "Don\'t show again" in the welcome tour. Uncheck to show the tour again on all devices.',
|
|
199
|
+
default: false
|
|
200
|
+
},
|
|
201
|
+
|
|
134
202
|
apps: {
|
|
135
203
|
type: 'array',
|
|
136
204
|
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,a){a.r(t),a.d(t,{default:()=>s});var l=a(231);const n={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:a,size:o}){const i=o||36,r={...n.iconBox,width:i,height:i,borderRadius:.22*i,...a?{background:a}:{}},c=e||"";return c.startsWith("/")||c.startsWith("http")?l.createElement("div",{style:r},l.createElement("img",{src:c,alt:t,style:{...n.iconImg,padding:.12*i},onError:e=>{e.target.style.display="none",e.target.parentNode.textContent=(t||"??").slice(0,2).toUpperCase()}})):l.createElement("div",{style:r},c||(t||"??").slice(0,2).toUpperCase())}function i({label:e,value:t,options:a,onChange:o,hint:i}){return l.createElement("div",{style:n.fieldRow},l.createElement("span",{style:n.label},e),l.createElement("select",{style:n.select,value:t,onChange:e=>o(e.target.value)},a.map(e=>l.createElement("option",{key:e.value,value:e.value},e.label))),i&&l.createElement("span",{style:n.hint},i))}function r({label:e,value:t,onChange:a,hint:o}){return l.createElement("div",{style:n.fieldRow},l.createElement("span",{style:n.label},e),l.createElement("input",{style:n.input,type:"number",value:t,onChange:e=>a(Number(e.target.value))}),o&&l.createElement("span",{style:n.hint},o))}function c({label:e,checked:t,onChange:a,hint:o}){return l.createElement("div",{style:n.fieldRow},l.createElement("span",{style:n.label},e),l.createElement("input",{type:"checkbox",style:n.checkboxField,checked:t,onChange:e=>a(e.target.checked)}),o&&l.createElement("span",{style:n.hint},o))}function s({configuration:e,save:t}){const a=e||{},[s,d]=(0,l.useState)(()=>a.apps||[]),[p,u]=(0,l.useState)(a.position||"bottom"),[g,m]=(0,l.useState)(a.triggerCorner||"bottom-right"),[b,f]=(0,l.useState)(a.iframeMode||"keep-alive"),[h,v]=(0,l.useState)(a.iconSize||56),[y,k]=(0,l.useState)(!1!==a.magnification),[E,S]=(0,l.useState)(a.magnificationScale||1.7),[x,C]=(0,l.useState)(""),[w,D]=(0,l.useState)(!1),[z,R]=(0,l.useState)(!1),[I,T]=(0,l.useState)(null),[A,B]=(0,l.useState)(null),W=(0,l.useRef)(s);W.current=s;const F=(0,l.useCallback)(e=>({position:p,triggerCorner:g,iframeMode:b,iconSize:h,magnification:y,magnificationScale:E,apps:e}),[p,g,b,h,y,E]),M=(0,l.useCallback)(()=>{t(F(s)),C("Saved!"),D(!1)},[s,F,t]),N=()=>B(null),$=()=>{T(null),B(null)},_=s.filter(e=>!1!==e.enabled);return l.createElement("div",{style:n.root},l.createElement("div",{style:n.sectionTitle},"Dock Settings"),l.createElement(i,{label:"Dock position",value:p,onChange:u,options:[{value:"bottom",label:"Bottom"},{value:"top",label:"Top"},{value:"left",label:"Left"},{value:"right",label:"Right"}]}),l.createElement(i,{label:"Double-tap corner",value:g,onChange:m,options:[{value:"bottom-right",label:"Bottom-right"},{value:"bottom-left",label:"Bottom-left"},{value:"top-right",label:"Top-right"},{value:"top-left",label:"Top-left"}]}),l.createElement(i,{label:"iFrame lifecycle",value:b,onChange:f,options:[{value:"keep-alive",label:"Keep-alive (faster switching, more RAM)"},{value:"destroy",label:"Destroy (reload each time, less RAM)"}]}),l.createElement(r,{label:"Icon size",value:h,onChange:v,hint:"px"}),l.createElement(c,{label:"Magnification effect",checked:y,onChange:k}),y&&l.createElement(r,{label:"Magnification scale",value:E,onChange:S,hint:"1.0 - 2.5"}),l.createElement("div",{style:n.sectionTitle},"Webapps"),l.createElement("button",{style:{...n.btn,...n.btnPrimary,...z?{opacity:.6}:{}},onClick:async()=>{R(!0),C(""),D(!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 C("No webapps found. Is the server fully started?"),D(!0),void R(!1);const a=W.current,l=new Set(a.map(e=>e.url));let n=0;const o=[...a];for(const e of t)l.has(e.url)||(o.push({enabled:!0,autostart:!1,url:e.url,label:e.label||"",icon:e.icon||"",color:e.isAdmin?"#78788c":""}),n++);d(o),C(n>0?`Found ${t.length} webapps, added ${n} new.`:`All ${t.length} webapps already in list.`),D(!1)}catch(e){C("Discovery failed: "+e.message),D(!0)}R(!1)},disabled:z},z?"Discovering…":"Discover Installed Webapps"),x&&l.createElement("div",{style:{...n.status,color:w?"#ef4444":"#10b981"}},x),l.createElement("div",{style:n.sectionTitle},"Dock Apps (drag to reorder)"),0===s.length?l.createElement("div",{style:n.empty},"No apps yet. Click Discover above."):l.createElement("div",null,s.map((e,t)=>l.createElement("div",{key:e.url+t,draggable:!0,onDragStart:()=>(e=>T(e))(t),onDragOver:e=>((e,t)=>{e.preventDefault(),B(t)})(e,t),onDragLeave:N,onDrop:e=>((e,t)=>{if(e.preventDefault(),B(null),null!==I&&I!==t){const e=[...s],[a]=e.splice(I,1);e.splice(t,0,a),d(e)}T(null)})(e,t),onDragEnd:$,style:{...n.item,...!1===e.enabled?n.itemDisabled:{},...A===t?n.dragOver:{},...I===t?{opacity:.4}:{}}},l.createElement("span",{style:n.handle},"≡"),l.createElement(o,{icon:e.icon,label:e.label||e.url,color:e.color}),l.createElement("div",{style:n.info},l.createElement("div",{style:n.name},e.label||e.url),l.createElement("div",{style:n.url},e.url)),l.createElement("div",{style:n.toggle,title:"Enabled"},l.createElement("input",{type:"checkbox",checked:!1!==e.enabled,onChange:()=>(e=>d(s.map((t,a)=>a===e?{...t,enabled:!t.enabled}:t)))(t),style:n.checkbox})),l.createElement("div",{style:{...n.toggle,cursor:"pointer",fontSize:16,opacity:e.autostart?1:.25},onClick:()=>(e=>d(s.map((t,a)=>a===e?{...t,autostart:!t.autostart}:{...t,autostart:!1})))(t),title:e.autostart?"Autostart: ON (click to disable)":"Click to set as autostart app"},"▶"),l.createElement("button",{style:{...n.btn,...n.btnDanger},onClick:()=>(e=>d(s.filter((t,a)=>a!==e)))(t)},"Remove")))),_.length>0&&l.createElement(l.Fragment,null,l.createElement("div",{style:n.sectionTitle},"Preview"),l.createElement("div",{style:n.preview},_.map((e,t)=>l.createElement(o,{key:t,icon:e.icon,label:e.label||e.url,color:e.color,size:40})))),l.createElement("div",{style:n.actions},l.createElement("button",{style:{...n.btn,...n.btnSave},onClick:M},"Save Configuration")))}}}]);
|
|
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.triggerCorner||"bottom-right"),[b,f]=(0,a.useState)(n.iframeMode||"keep-alive"),[h,v]=(0,a.useState)(n.iconSize||56),[y,k]=(0,a.useState)(!1!==n.magnification),[E,S]=(0,a.useState)(n.magnificationScale||1.7),[x,w]=(0,a.useState)(n.showNightModeButton||!1),[C,z]=(0,a.useState)(n.showExitButton||!1),[D,R]=(0,a.useState)(""),[B,T]=(0,a.useState)(!1),[I,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,triggerCorner:p,iframeMode:b,iconSize:h,magnification:y,magnificationScale:E,showNightModeButton:x,showExitButton:C,apps:e}),[u,p,b,h,y,E,x,C]),L=(0,a.useCallback)(async()=>{U.current&&clearTimeout(U.current),M(!0),R("Saving…"),T(!1);try{const e=t(j(s));e&&"function"==typeof e.then&&await e,R("Configuration saved — plugin will restart"),T(!1),U.current=setTimeout(()=>R(""),5e3)}catch(e){R("Save failed: "+(e.message||e)),T(!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:"Double-tap corner",value:p,onChange:m,options:[{value:"bottom-right",label:"Bottom-right"},{value:"bottom-left",label:"Bottom-left"},{value:"top-right",label:"Top-right"},{value:"top-left",label:"Top-left"}]}),a.createElement(i,{label:"iFrame lifecycle",value:b,onChange:f,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:h,onChange:v,hint:"px"}),a.createElement(c,{label:"Magnification effect",checked:y,onChange:k}),y&&a.createElement(r,{label:"Magnification scale",value:E,onChange:S,hint:"1.0 - 2.5"}),a.createElement("div",{style:l.sectionTitle},"Utility Buttons"),a.createElement(c,{label:"Night/Day mode toggle",checked:x,onChange:w,hint:"Sun/moon button to toggle environment.mode"}),a.createElement(c,{label:"Exit button",checked:C,onChange:z,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,...I?{opacity:.6}:{}},onClick:async()=>{F(!0),R(""),T(!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?"),T(!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.`),T(!1)}catch(e){R("Discovery failed: "+e.message),T(!0)}F(!1)},disabled:I},I?"Discovering…":"Discover Installed Webapps"),D&&a.createElement("div",{style:{...l.status,color:B?"#ef4444":"#10b981"}},D),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||x||C)&&a.createElement(a.Fragment,null,a.createElement("div",{style:l.sectionTitle},"Preview"),a.createElement("div",{style:l.preview},x&&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})),C&&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"),D&&a.createElement("div",{style:{...l.status,color:B?"#ef4444":"#10b981"}},D)))}}}]);
|
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:
|
|
@@ -359,6 +359,43 @@ body {
|
|
|
359
359
|
transform: translateY(-50%);
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
/* ─── Dock separator ───────────────────────────────────────────────────────── */
|
|
363
|
+
.dock-separator {
|
|
364
|
+
width: 1px;
|
|
365
|
+
height: 32px;
|
|
366
|
+
background: rgba(255, 255, 255, 0.15);
|
|
367
|
+
flex-shrink: 0;
|
|
368
|
+
align-self: center;
|
|
369
|
+
margin: 0 2px;
|
|
370
|
+
border-radius: 1px;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.layout-vertical .dock-separator {
|
|
374
|
+
width: 32px;
|
|
375
|
+
height: 1px;
|
|
376
|
+
margin: 2px 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* ─── Utility buttons (night mode, exit) ───────────────────────────────────── */
|
|
380
|
+
.dock-icon-utility {
|
|
381
|
+
background: rgba(255, 255, 255, 0.08);
|
|
382
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
383
|
+
box-shadow: none;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.dock-item-utility .dock-dot {
|
|
387
|
+
display: none;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.dock-item-utility:hover .dock-icon-utility,
|
|
391
|
+
.dock-item-utility.pressing .dock-icon-utility {
|
|
392
|
+
background: rgba(255, 255, 255, 0.15);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.dock-item-exit:hover .dock-icon-utility {
|
|
396
|
+
background: rgba(239, 68, 68, 0.25);
|
|
397
|
+
}
|
|
398
|
+
|
|
362
399
|
/* ─── Invisible corner trigger zone ────────────────────────────────────────── */
|
|
363
400
|
#trigger-corner {
|
|
364
401
|
position: fixed;
|
|
@@ -410,3 +447,115 @@ body {
|
|
|
410
447
|
transform: rotate(360deg);
|
|
411
448
|
}
|
|
412
449
|
}
|
|
450
|
+
|
|
451
|
+
/* ─── Welcome tour ─────────────────────────────────────────────────────────── */
|
|
452
|
+
#tour-overlay {
|
|
453
|
+
position: fixed;
|
|
454
|
+
inset: 0;
|
|
455
|
+
z-index: 30;
|
|
456
|
+
display: flex;
|
|
457
|
+
align-items: center;
|
|
458
|
+
justify-content: center;
|
|
459
|
+
background: rgba(0, 0, 0, 0.6);
|
|
460
|
+
backdrop-filter: blur(4px);
|
|
461
|
+
-webkit-backdrop-filter: blur(4px);
|
|
462
|
+
opacity: 0;
|
|
463
|
+
pointer-events: none;
|
|
464
|
+
transition: opacity 0.3s ease;
|
|
465
|
+
padding: 24px;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#tour-overlay.visible {
|
|
469
|
+
opacity: 1;
|
|
470
|
+
pointer-events: auto;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#tour-card {
|
|
474
|
+
max-width: 520px;
|
|
475
|
+
width: 100%;
|
|
476
|
+
background: rgba(30, 30, 32, 0.9);
|
|
477
|
+
backdrop-filter: blur(40px) saturate(180%);
|
|
478
|
+
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
|
479
|
+
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
480
|
+
border-radius: 22px;
|
|
481
|
+
padding: 32px;
|
|
482
|
+
color: rgba(255, 255, 255, 0.92);
|
|
483
|
+
box-shadow:
|
|
484
|
+
0 24px 64px rgba(0, 0, 0, 0.6),
|
|
485
|
+
inset 0 0.5px 0 rgba(255, 255, 255, 0.15);
|
|
486
|
+
/* Position above the dock so user can see both */
|
|
487
|
+
margin-bottom: 180px;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
#tour-title {
|
|
491
|
+
font-size: 22px;
|
|
492
|
+
font-weight: 700;
|
|
493
|
+
letter-spacing: 0.01em;
|
|
494
|
+
margin-bottom: 16px;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
#tour-body {
|
|
498
|
+
font-size: 15px;
|
|
499
|
+
line-height: 1.55;
|
|
500
|
+
color: rgba(255, 255, 255, 0.78);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#tour-body p {
|
|
504
|
+
margin-bottom: 12px;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
#tour-body p:last-child {
|
|
508
|
+
margin-bottom: 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#tour-link {
|
|
512
|
+
color: #60a5fa;
|
|
513
|
+
text-decoration: none;
|
|
514
|
+
font-weight: 600;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
#tour-link:hover {
|
|
518
|
+
text-decoration: underline;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
#tour-buttons {
|
|
522
|
+
display: flex;
|
|
523
|
+
gap: 10px;
|
|
524
|
+
margin-top: 24px;
|
|
525
|
+
justify-content: flex-end;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
.tour-btn {
|
|
529
|
+
padding: 10px 20px;
|
|
530
|
+
border: none;
|
|
531
|
+
border-radius: 10px;
|
|
532
|
+
font-size: 14px;
|
|
533
|
+
font-weight: 600;
|
|
534
|
+
cursor: pointer;
|
|
535
|
+
font-family: inherit;
|
|
536
|
+
transition:
|
|
537
|
+
background 0.15s ease,
|
|
538
|
+
transform 0.1s ease;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.tour-btn-primary {
|
|
542
|
+
background: #3b82f6;
|
|
543
|
+
color: #fff;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.tour-btn-primary:hover {
|
|
547
|
+
background: #2563eb;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.tour-btn-dismiss {
|
|
551
|
+
background: rgba(255, 255, 255, 0.08);
|
|
552
|
+
color: rgba(255, 255, 255, 0.8);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
.tour-btn-dismiss:hover {
|
|
556
|
+
background: rgba(255, 255, 255, 0.15);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
.tour-btn:active {
|
|
560
|
+
transform: scale(0.97);
|
|
561
|
+
}
|
package/public/dock.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
iconSize: 56,
|
|
9
9
|
magnification: true,
|
|
10
10
|
magnificationScale: 1.7,
|
|
11
|
+
showNightModeButton: false,
|
|
12
|
+
showExitButton: false,
|
|
11
13
|
apps: []
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -52,6 +54,48 @@
|
|
|
52
54
|
let dockVisible = false
|
|
53
55
|
const iframes = {}
|
|
54
56
|
|
|
57
|
+
// ─── Night mode state ────────────────────────────────────────────────────────
|
|
58
|
+
let currentMode = 'day'
|
|
59
|
+
|
|
60
|
+
async function fetchCurrentMode() {
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch('/plugins/signalk-app-dock/mode')
|
|
63
|
+
if (res.ok) {
|
|
64
|
+
const data = await res.json()
|
|
65
|
+
currentMode = data.value || 'day'
|
|
66
|
+
updateNightModeIcon()
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn('[Dock] Could not fetch environment.mode', e)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function updateNightModeIcon() {
|
|
74
|
+
const icon = document.querySelector('.dock-item-nightmode .dock-icon')
|
|
75
|
+
if (!icon) return
|
|
76
|
+
icon.textContent = currentMode === 'night' ? '\u{1F319}' : '\u2600\uFE0F'
|
|
77
|
+
const label = document.querySelector('.dock-item-nightmode .dock-label')
|
|
78
|
+
if (label) label.textContent = currentMode === 'night' ? 'Day mode' : 'Night mode'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function toggleNightMode() {
|
|
82
|
+
await fetchCurrentMode()
|
|
83
|
+
const newMode = currentMode === 'night' ? 'day' : 'night'
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch('/signalk/v1/api/vessels/self/environment/mode', {
|
|
86
|
+
method: 'PUT',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ value: newMode })
|
|
89
|
+
})
|
|
90
|
+
if (res.ok) {
|
|
91
|
+
currentMode = newMode
|
|
92
|
+
updateNightModeIcon()
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.warn('[Dock] Failed to toggle mode', e)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
55
99
|
// ─── Apply dock position class & alignment ───────────────────────────────────
|
|
56
100
|
const pos = cfg.position
|
|
57
101
|
$dock.classList.add(`pos-${pos}`)
|
|
@@ -119,6 +163,55 @@
|
|
|
119
163
|
}
|
|
120
164
|
}
|
|
121
165
|
|
|
166
|
+
// ─── Utility item builder ─────────────────────────────────────────────────────
|
|
167
|
+
function createUtilityItem(className, icon, label, onClick) {
|
|
168
|
+
const sz = cfg.iconSize
|
|
169
|
+
const radius = Math.round(sz * 0.25) + 'px'
|
|
170
|
+
const lblCls = labelClass()
|
|
171
|
+
|
|
172
|
+
const $item = document.createElement('div')
|
|
173
|
+
$item.className = 'dock-item dock-item-utility ' + className
|
|
174
|
+
|
|
175
|
+
const $icon = document.createElement('div')
|
|
176
|
+
$icon.className = 'dock-icon dock-icon-utility'
|
|
177
|
+
$icon.style.width = sz + 'px'
|
|
178
|
+
$icon.style.height = sz + 'px'
|
|
179
|
+
$icon.style.borderRadius = radius
|
|
180
|
+
$icon.style.fontSize = Math.round(sz * 0.48) + 'px'
|
|
181
|
+
$icon.textContent = icon
|
|
182
|
+
|
|
183
|
+
const $dot = document.createElement('div')
|
|
184
|
+
$dot.className = 'dock-dot'
|
|
185
|
+
|
|
186
|
+
const $label = document.createElement('div')
|
|
187
|
+
$label.className = 'dock-label' + (lblCls ? ' ' + lblCls : '')
|
|
188
|
+
$label.textContent = label
|
|
189
|
+
|
|
190
|
+
$item.appendChild($icon)
|
|
191
|
+
$item.appendChild($dot)
|
|
192
|
+
$item.appendChild($label)
|
|
193
|
+
|
|
194
|
+
$item.addEventListener('touchstart', () => $item.classList.add('pressing'), { passive: true })
|
|
195
|
+
$item.addEventListener('touchend', () => $item.classList.remove('pressing'), { passive: true })
|
|
196
|
+
$item.addEventListener('touchcancel', () => $item.classList.remove('pressing'), { passive: true })
|
|
197
|
+
|
|
198
|
+
$item.addEventListener('click', (e) => {
|
|
199
|
+
if (magScrubbed) {
|
|
200
|
+
e.preventDefault()
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
onClick()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
return $item
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function createSeparator() {
|
|
210
|
+
const $sep = document.createElement('div')
|
|
211
|
+
$sep.className = 'dock-separator'
|
|
212
|
+
return $sep
|
|
213
|
+
}
|
|
214
|
+
|
|
122
215
|
// ─── Build dock items ─────────────────────────────────────────────────────────
|
|
123
216
|
function buildDock() {
|
|
124
217
|
$dockInner.innerHTML = ''
|
|
@@ -126,6 +219,11 @@
|
|
|
126
219
|
const radius = Math.round(sz * 0.25) + 'px'
|
|
127
220
|
const lblCls = labelClass()
|
|
128
221
|
|
|
222
|
+
if (cfg.showNightModeButton) {
|
|
223
|
+
$dockInner.appendChild(createUtilityItem('dock-item-nightmode', '\u2600\uFE0F', 'Night mode', toggleNightMode))
|
|
224
|
+
$dockInner.appendChild(createSeparator())
|
|
225
|
+
}
|
|
226
|
+
|
|
129
227
|
cfg.apps.forEach((app, i) => {
|
|
130
228
|
const $item = document.createElement('div')
|
|
131
229
|
$item.className = 'dock-item'
|
|
@@ -179,6 +277,17 @@
|
|
|
179
277
|
|
|
180
278
|
$dockInner.appendChild($item)
|
|
181
279
|
})
|
|
280
|
+
|
|
281
|
+
if (cfg.showExitButton) {
|
|
282
|
+
$dockInner.appendChild(createSeparator())
|
|
283
|
+
$dockInner.appendChild(
|
|
284
|
+
createUtilityItem('dock-item-exit', '\u2715', 'Exit to Admin', () => {
|
|
285
|
+
window.location.href = '/admin/'
|
|
286
|
+
})
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (cfg.showNightModeButton) updateNightModeIcon()
|
|
182
291
|
}
|
|
183
292
|
|
|
184
293
|
// ─── Magnification ───────────────────────────────────────────────────────────
|
|
@@ -311,6 +420,7 @@
|
|
|
311
420
|
|
|
312
421
|
function hideDock() {
|
|
313
422
|
if (!dockVisible) return
|
|
423
|
+
if ($tourOverlay && $tourOverlay.classList.contains('visible')) return
|
|
314
424
|
dockVisible = false
|
|
315
425
|
$dock.classList.remove('visible')
|
|
316
426
|
$backdrop.classList.remove('visible')
|
|
@@ -447,6 +557,88 @@
|
|
|
447
557
|
positionCornerZone()
|
|
448
558
|
buildDock()
|
|
449
559
|
|
|
560
|
+
if (cfg.showNightModeButton) {
|
|
561
|
+
fetchCurrentMode()
|
|
562
|
+
setInterval(fetchCurrentMode, 5000)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function refreshConfig() {
|
|
566
|
+
try {
|
|
567
|
+
const [configRes, settingsRes] = await Promise.all([
|
|
568
|
+
fetch('/plugins/signalk-app-dock/config'),
|
|
569
|
+
fetch('/plugins/signalk-app-dock/settings')
|
|
570
|
+
])
|
|
571
|
+
const prev = JSON.stringify(cfg)
|
|
572
|
+
if (configRes.ok) {
|
|
573
|
+
const data = await configRes.json()
|
|
574
|
+
const pluginCfg = data.configuration || data
|
|
575
|
+
cfg = { ...DEFAULTS, ...pluginCfg }
|
|
576
|
+
}
|
|
577
|
+
if (settingsRes.ok) {
|
|
578
|
+
const data = await settingsRes.json()
|
|
579
|
+
if (Array.isArray(data.apps) && data.apps.length > 0) {
|
|
580
|
+
cfg.apps = data.apps
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (JSON.stringify(cfg) !== prev) {
|
|
584
|
+
buildDock()
|
|
585
|
+
}
|
|
586
|
+
} catch {
|
|
587
|
+
// ignore transient errors (e.g. during plugin restart)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
setInterval(refreshConfig, 5000)
|
|
592
|
+
|
|
593
|
+
// ─── Welcome tour ────────────────────────────────────────────────────────────
|
|
594
|
+
const $tourOverlay = document.getElementById('tour-overlay')
|
|
595
|
+
const $tourGotIt = document.getElementById('tour-btn-got-it')
|
|
596
|
+
const $tourDismiss = document.getElementById('tour-btn-dismiss')
|
|
597
|
+
|
|
598
|
+
function hideTour() {
|
|
599
|
+
$tourOverlay.classList.remove('visible')
|
|
600
|
+
hideDock()
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function showTour() {
|
|
604
|
+
$tourOverlay.classList.add('visible')
|
|
605
|
+
showDock()
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if ($tourGotIt) $tourGotIt.addEventListener('click', hideTour)
|
|
609
|
+
if ($tourDismiss) {
|
|
610
|
+
$tourDismiss.addEventListener('click', async () => {
|
|
611
|
+
hideTour()
|
|
612
|
+
try {
|
|
613
|
+
await fetch('/plugins/signalk-app-dock/dismiss-tour', { method: 'POST' })
|
|
614
|
+
} catch {
|
|
615
|
+
// ignore network errors; worst case user sees the tour again next reload
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const $tourLink = document.getElementById('tour-link')
|
|
621
|
+
if ($tourLink) {
|
|
622
|
+
$tourLink.addEventListener('click', (e) => {
|
|
623
|
+
e.preventDefault()
|
|
624
|
+
const adminUrl = '/admin/#/serverConfiguration/plugins/signalk-app-dock'
|
|
625
|
+
hideIdleHint()
|
|
626
|
+
const $frame = document.createElement('iframe')
|
|
627
|
+
$frame.allow = 'fullscreen; geolocation'
|
|
628
|
+
$frame.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-pointer-lock'
|
|
629
|
+
$frame.style.touchAction = 'auto'
|
|
630
|
+
$frame.classList.add('active')
|
|
631
|
+
$iframeContainer.innerHTML = ''
|
|
632
|
+
Object.keys(iframes).forEach((k) => delete iframes[k])
|
|
633
|
+
$iframeContainer.appendChild($frame)
|
|
634
|
+
$frame.src = adminUrl
|
|
635
|
+
iframes[adminUrl] = $frame
|
|
636
|
+
hideTour()
|
|
637
|
+
})
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (!cfg.tourDismissed) showTour()
|
|
641
|
+
|
|
450
642
|
const autostartIdx = cfg.apps.findIndex((a) => a.autostart)
|
|
451
643
|
if (autostartIdx >= 0) {
|
|
452
644
|
switchToApp(autostartIdx)
|
package/public/index.html
CHANGED
|
@@ -36,6 +36,29 @@
|
|
|
36
36
|
<span id="loading-label"></span>
|
|
37
37
|
</div>
|
|
38
38
|
|
|
39
|
+
<!-- First-run welcome tour -->
|
|
40
|
+
<div id="tour-overlay" aria-hidden="true">
|
|
41
|
+
<div id="tour-card" role="dialog" aria-labelledby="tour-title">
|
|
42
|
+
<div id="tour-title">Welcome to App Dock</div>
|
|
43
|
+
<div id="tour-body">
|
|
44
|
+
<p>App Dock gives you a macOS-style dock to quickly switch between Signal K webapps on touch screens.</p>
|
|
45
|
+
<p>
|
|
46
|
+
<strong>Double-tap the bottom-right corner</strong> of the screen to open the dock, then tap an icon to
|
|
47
|
+
launch that app.
|
|
48
|
+
</p>
|
|
49
|
+
<p>
|
|
50
|
+
Freeboard-SK is loading in the background and KIP is one tap away. Customize the apps, dock position, and
|
|
51
|
+
more in
|
|
52
|
+
<a id="tour-link" href="#">Plugin Config</a>.
|
|
53
|
+
</p>
|
|
54
|
+
</div>
|
|
55
|
+
<div id="tour-buttons">
|
|
56
|
+
<button id="tour-btn-got-it" class="tour-btn tour-btn-primary">Got it</button>
|
|
57
|
+
<button id="tour-btn-dismiss" class="tour-btn tour-btn-dismiss">Don't show again</button>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
39
62
|
<script src="dock.js"></script>
|
|
40
63
|
</body>
|
|
41
64
|
</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: {
|
|
@@ -180,16 +180,26 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
180
180
|
const [iconSize, setIconSize] = useState(cfg.iconSize || 56)
|
|
181
181
|
const [magnification, setMagnification] = useState(cfg.magnification !== false)
|
|
182
182
|
const [magnificationScale, setMagnificationScale] = useState(cfg.magnificationScale || 1.7)
|
|
183
|
+
const [showNightModeButton, setShowNightModeButton] = useState(cfg.showNightModeButton || false)
|
|
184
|
+
const [showExitButton, setShowExitButton] = useState(cfg.showExitButton || false)
|
|
183
185
|
|
|
184
186
|
const [status, setStatus] = useState('')
|
|
185
187
|
const [statusError, setStatusError] = useState(false)
|
|
186
188
|
const [discovering, setDiscovering] = useState(false)
|
|
189
|
+
const [saving, setSaving] = useState(false)
|
|
187
190
|
const [dragIdx, setDragIdx] = useState(null)
|
|
188
191
|
const [overIdx, setOverIdx] = useState(null)
|
|
189
192
|
|
|
190
193
|
const appsRef = useRef(apps)
|
|
191
194
|
appsRef.current = apps
|
|
192
195
|
|
|
196
|
+
const statusTimeoutRef = useRef(null)
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
return () => {
|
|
199
|
+
if (statusTimeoutRef.current) clearTimeout(statusTimeoutRef.current)
|
|
200
|
+
}
|
|
201
|
+
}, [])
|
|
202
|
+
|
|
193
203
|
const buildConfig = useCallback(
|
|
194
204
|
(appsList) => ({
|
|
195
205
|
position,
|
|
@@ -198,15 +208,41 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
198
208
|
iconSize,
|
|
199
209
|
magnification,
|
|
200
210
|
magnificationScale,
|
|
211
|
+
showNightModeButton,
|
|
212
|
+
showExitButton,
|
|
201
213
|
apps: appsList
|
|
202
214
|
}),
|
|
203
|
-
[
|
|
215
|
+
[
|
|
216
|
+
position,
|
|
217
|
+
triggerCorner,
|
|
218
|
+
iframeMode,
|
|
219
|
+
iconSize,
|
|
220
|
+
magnification,
|
|
221
|
+
magnificationScale,
|
|
222
|
+
showNightModeButton,
|
|
223
|
+
showExitButton
|
|
224
|
+
]
|
|
204
225
|
)
|
|
205
226
|
|
|
206
|
-
const doSave = useCallback(() => {
|
|
207
|
-
|
|
208
|
-
|
|
227
|
+
const doSave = useCallback(async () => {
|
|
228
|
+
if (statusTimeoutRef.current) clearTimeout(statusTimeoutRef.current)
|
|
229
|
+
setSaving(true)
|
|
230
|
+
setStatus('Saving\u2026')
|
|
209
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
|
+
}
|
|
210
246
|
}, [apps, buildConfig, save])
|
|
211
247
|
|
|
212
248
|
const discover = async () => {
|
|
@@ -355,6 +391,22 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
355
391
|
/>
|
|
356
392
|
)}
|
|
357
393
|
|
|
394
|
+
<div style={S.sectionTitle}>Utility Buttons</div>
|
|
395
|
+
|
|
396
|
+
<CheckboxField
|
|
397
|
+
label="Night/Day mode toggle"
|
|
398
|
+
checked={showNightModeButton}
|
|
399
|
+
onChange={setShowNightModeButton}
|
|
400
|
+
hint="Sun/moon button to toggle environment.mode"
|
|
401
|
+
/>
|
|
402
|
+
|
|
403
|
+
<CheckboxField
|
|
404
|
+
label="Exit button"
|
|
405
|
+
checked={showExitButton}
|
|
406
|
+
onChange={setShowExitButton}
|
|
407
|
+
hint="X button to return to Signal K admin UI"
|
|
408
|
+
/>
|
|
409
|
+
|
|
358
410
|
<div style={S.sectionTitle}>Webapps</div>
|
|
359
411
|
<button
|
|
360
412
|
style={{ ...S.btn, ...S.btnPrimary, ...(discovering ? { opacity: 0.6 } : {}) }}
|
|
@@ -420,21 +472,63 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
420
472
|
</div>
|
|
421
473
|
)}
|
|
422
474
|
|
|
423
|
-
{enabledApps.length > 0 && (
|
|
475
|
+
{(enabledApps.length > 0 || showNightModeButton || showExitButton) && (
|
|
424
476
|
<>
|
|
425
477
|
<div style={S.sectionTitle}>Preview</div>
|
|
426
478
|
<div style={S.preview}>
|
|
479
|
+
{showNightModeButton && (
|
|
480
|
+
<>
|
|
481
|
+
<div
|
|
482
|
+
style={{
|
|
483
|
+
...S.iconBox,
|
|
484
|
+
width: 40,
|
|
485
|
+
height: 40,
|
|
486
|
+
background: 'rgba(255,255,255,0.08)',
|
|
487
|
+
border: '1px solid rgba(255,255,255,0.12)',
|
|
488
|
+
fontSize: 20,
|
|
489
|
+
borderRadius: 10
|
|
490
|
+
}}
|
|
491
|
+
>
|
|
492
|
+
{'\u2600\uFE0F'}
|
|
493
|
+
</div>
|
|
494
|
+
<div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.15)', alignSelf: 'center' }} />
|
|
495
|
+
</>
|
|
496
|
+
)}
|
|
427
497
|
{enabledApps.map((app, i) => (
|
|
428
498
|
<IconPreview key={i} icon={app.icon} label={app.label || app.url} color={app.color} size={40} />
|
|
429
499
|
))}
|
|
500
|
+
{showExitButton && (
|
|
501
|
+
<>
|
|
502
|
+
<div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.15)', alignSelf: 'center' }} />
|
|
503
|
+
<div
|
|
504
|
+
style={{
|
|
505
|
+
...S.iconBox,
|
|
506
|
+
width: 40,
|
|
507
|
+
height: 40,
|
|
508
|
+
background: 'rgba(255,255,255,0.08)',
|
|
509
|
+
border: '1px solid rgba(255,255,255,0.12)',
|
|
510
|
+
fontSize: 16,
|
|
511
|
+
borderRadius: 10,
|
|
512
|
+
color: '#fff'
|
|
513
|
+
}}
|
|
514
|
+
>
|
|
515
|
+
{'\u2715'}
|
|
516
|
+
</div>
|
|
517
|
+
</>
|
|
518
|
+
)}
|
|
430
519
|
</div>
|
|
431
520
|
</>
|
|
432
521
|
)}
|
|
433
522
|
|
|
434
523
|
<div style={S.actions}>
|
|
435
|
-
<button
|
|
436
|
-
|
|
524
|
+
<button
|
|
525
|
+
style={{ ...S.btn, ...S.btnSave, ...(saving ? { opacity: 0.6 } : {}) }}
|
|
526
|
+
onClick={doSave}
|
|
527
|
+
disabled={saving}
|
|
528
|
+
>
|
|
529
|
+
{saving ? 'Saving\u2026' : 'Save Configuration'}
|
|
437
530
|
</button>
|
|
531
|
+
{status && <div style={{ ...S.status, color: statusError ? '#ef4444' : '#10b981' }}>{status}</div>}
|
|
438
532
|
</div>
|
|
439
533
|
</div>
|
|
440
534
|
)
|
package/test/plugin.test.js
CHANGED
|
@@ -12,7 +12,10 @@ function createMockApp(webapps = []) {
|
|
|
12
12
|
webapps,
|
|
13
13
|
debug: () => {},
|
|
14
14
|
error: () => {},
|
|
15
|
-
savePluginOptions: (opts, cb) => cb(null)
|
|
15
|
+
savePluginOptions: (opts, cb) => cb(null),
|
|
16
|
+
registerPutHandler: () => {},
|
|
17
|
+
handleMessage: () => {},
|
|
18
|
+
getSelfPath: () => null
|
|
16
19
|
}
|
|
17
20
|
}
|
|
18
21
|
|
|
@@ -74,6 +77,32 @@ describe('schema', () => {
|
|
|
74
77
|
const items = plugin.schema.properties.apps.items
|
|
75
78
|
assert.deepEqual(items.required, ['url'])
|
|
76
79
|
})
|
|
80
|
+
|
|
81
|
+
it('defines showNightModeButton with default true', () => {
|
|
82
|
+
const plugin = pluginFactory(createMockApp())
|
|
83
|
+
const prop = plugin.schema.properties.showNightModeButton
|
|
84
|
+
assert.equal(prop.type, 'boolean')
|
|
85
|
+
assert.equal(prop.default, true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('defines showExitButton with default false', () => {
|
|
89
|
+
const plugin = pluginFactory(createMockApp())
|
|
90
|
+
const prop = plugin.schema.properties.showExitButton
|
|
91
|
+
assert.equal(prop.type, 'boolean')
|
|
92
|
+
assert.equal(prop.default, false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('triggerCorner defaults to bottom-right', () => {
|
|
96
|
+
const plugin = pluginFactory(createMockApp())
|
|
97
|
+
assert.equal(plugin.schema.properties.triggerCorner.default, 'bottom-right')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('defines tourDismissed with default false', () => {
|
|
101
|
+
const plugin = pluginFactory(createMockApp())
|
|
102
|
+
const prop = plugin.schema.properties.tourDismissed
|
|
103
|
+
assert.equal(prop.type, 'boolean')
|
|
104
|
+
assert.equal(prop.default, false)
|
|
105
|
+
})
|
|
77
106
|
})
|
|
78
107
|
|
|
79
108
|
describe('webapp discovery', () => {
|
|
@@ -84,7 +113,8 @@ describe('webapp discovery', () => {
|
|
|
84
113
|
const router = {
|
|
85
114
|
get: (path, handler) => {
|
|
86
115
|
routes[path] = handler
|
|
87
|
-
}
|
|
116
|
+
},
|
|
117
|
+
post: () => {}
|
|
88
118
|
}
|
|
89
119
|
plugin.registerWithRouter(router)
|
|
90
120
|
|
|
@@ -106,7 +136,8 @@ describe('webapp discovery', () => {
|
|
|
106
136
|
const router = {
|
|
107
137
|
get: (path, handler) => {
|
|
108
138
|
routes[path] = handler
|
|
109
|
-
}
|
|
139
|
+
},
|
|
140
|
+
post: () => {}
|
|
110
141
|
}
|
|
111
142
|
plugin.registerWithRouter(router)
|
|
112
143
|
|
|
@@ -129,7 +160,8 @@ describe('webapp discovery', () => {
|
|
|
129
160
|
const router = {
|
|
130
161
|
get: (path, handler) => {
|
|
131
162
|
routes[path] = handler
|
|
132
|
-
}
|
|
163
|
+
},
|
|
164
|
+
post: () => {}
|
|
133
165
|
}
|
|
134
166
|
plugin.registerWithRouter(router)
|
|
135
167
|
|
|
@@ -152,7 +184,8 @@ describe('webapp discovery', () => {
|
|
|
152
184
|
const router = {
|
|
153
185
|
get: (path, handler) => {
|
|
154
186
|
routes[path] = handler
|
|
155
|
-
}
|
|
187
|
+
},
|
|
188
|
+
post: () => {}
|
|
156
189
|
}
|
|
157
190
|
plugin.registerWithRouter(router)
|
|
158
191
|
|
|
@@ -176,7 +209,8 @@ describe('settings endpoint', () => {
|
|
|
176
209
|
const router = {
|
|
177
210
|
get: (path, handler) => {
|
|
178
211
|
routes[path] = handler
|
|
179
|
-
}
|
|
212
|
+
},
|
|
213
|
+
post: () => {}
|
|
180
214
|
}
|
|
181
215
|
plugin.registerWithRouter(router)
|
|
182
216
|
|
|
@@ -192,9 +226,9 @@ describe('settings endpoint', () => {
|
|
|
192
226
|
})
|
|
193
227
|
})
|
|
194
228
|
|
|
195
|
-
describe('
|
|
196
|
-
it('
|
|
197
|
-
const app = createMockApp(
|
|
229
|
+
describe('default apps seeding', () => {
|
|
230
|
+
it('seeds default apps when config has no apps key', () => {
|
|
231
|
+
const app = createMockApp()
|
|
198
232
|
let savedOptions = null
|
|
199
233
|
app.savePluginOptions = (opts, cb) => {
|
|
200
234
|
savedOptions = opts
|
|
@@ -202,73 +236,46 @@ describe('auto-discovery on start', () => {
|
|
|
202
236
|
}
|
|
203
237
|
|
|
204
238
|
const plugin = pluginFactory(app)
|
|
205
|
-
|
|
206
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
207
|
-
globalThis.setTimeout = (fn) => fn()
|
|
208
|
-
try {
|
|
209
|
-
plugin.start({ apps: [] })
|
|
210
|
-
} finally {
|
|
211
|
-
globalThis.setTimeout = originalSetTimeout
|
|
212
|
-
}
|
|
239
|
+
plugin.start({})
|
|
213
240
|
|
|
214
241
|
assert.ok(savedOptions)
|
|
215
|
-
assert.
|
|
216
|
-
assert.
|
|
217
|
-
assert.
|
|
218
|
-
|
|
242
|
+
assert.equal(savedOptions.apps[0].url, '/@signalk/freeboard-sk/')
|
|
243
|
+
assert.equal(savedOptions.apps[0].autostart, true)
|
|
244
|
+
assert.equal(savedOptions.apps[1].url, '/@mxtommy/kip/')
|
|
245
|
+
assert.equal(savedOptions.apps[1].autostart, false)
|
|
246
|
+
assert.equal(savedOptions.apps[2].url, '/admin/')
|
|
247
|
+
assert.equal(savedOptions.apps[2].label, 'Settings')
|
|
219
248
|
})
|
|
220
249
|
|
|
221
|
-
it('does not
|
|
222
|
-
const app = createMockApp(
|
|
223
|
-
let
|
|
224
|
-
app.savePluginOptions = (
|
|
225
|
-
|
|
226
|
-
cb(null)
|
|
250
|
+
it('does not seed when apps array already exists (even if empty)', () => {
|
|
251
|
+
const app = createMockApp()
|
|
252
|
+
let saveCalled = false
|
|
253
|
+
app.savePluginOptions = () => {
|
|
254
|
+
saveCalled = true
|
|
227
255
|
}
|
|
228
256
|
|
|
229
257
|
const plugin = pluginFactory(app)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
233
|
-
globalThis.setTimeout = (fn) => fn()
|
|
234
|
-
try {
|
|
235
|
-
plugin.start({ apps: existing })
|
|
236
|
-
} finally {
|
|
237
|
-
globalThis.setTimeout = originalSetTimeout
|
|
238
|
-
}
|
|
258
|
+
plugin.start({ apps: [] })
|
|
239
259
|
|
|
240
|
-
|
|
241
|
-
assert.equal(fbEntries.length, 1)
|
|
242
|
-
assert.equal(fbEntries[0].label, 'FB')
|
|
243
|
-
done()
|
|
260
|
+
assert.equal(saveCalled, false)
|
|
244
261
|
})
|
|
245
262
|
|
|
246
|
-
it('
|
|
247
|
-
const app = createMockApp(
|
|
263
|
+
it('preserves existing configured apps', () => {
|
|
264
|
+
const app = createMockApp()
|
|
248
265
|
let saveCalled = false
|
|
249
266
|
app.savePluginOptions = () => {
|
|
250
267
|
saveCalled = true
|
|
251
268
|
}
|
|
252
269
|
|
|
253
270
|
const plugin = pluginFactory(app)
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
|
|
257
|
-
]
|
|
258
|
-
|
|
259
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
260
|
-
globalThis.setTimeout = (fn) => fn()
|
|
261
|
-
try {
|
|
262
|
-
plugin.start({ apps: allConfigured })
|
|
263
|
-
} finally {
|
|
264
|
-
globalThis.setTimeout = originalSetTimeout
|
|
265
|
-
}
|
|
271
|
+
const existing = [{ enabled: true, url: '/custom-app/', label: 'Custom', icon: '', color: '' }]
|
|
272
|
+
plugin.start({ apps: existing })
|
|
266
273
|
|
|
267
274
|
assert.equal(saveCalled, false)
|
|
268
275
|
})
|
|
269
276
|
|
|
270
277
|
it('respects enabled:false when resolving apps', () => {
|
|
271
|
-
const app = createMockApp(
|
|
278
|
+
const app = createMockApp()
|
|
272
279
|
app.savePluginOptions = (_, cb) => cb(null)
|
|
273
280
|
const plugin = pluginFactory(app)
|
|
274
281
|
|
|
@@ -276,20 +283,14 @@ describe('auto-discovery on start', () => {
|
|
|
276
283
|
{ enabled: false, url: '/@signalk/freeboard-sk/', label: '', icon: '', color: '' },
|
|
277
284
|
{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
|
|
278
285
|
]
|
|
279
|
-
|
|
280
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
281
|
-
globalThis.setTimeout = (fn) => fn()
|
|
282
|
-
try {
|
|
283
|
-
plugin.start({ apps: configured })
|
|
284
|
-
} finally {
|
|
285
|
-
globalThis.setTimeout = originalSetTimeout
|
|
286
|
-
}
|
|
286
|
+
plugin.start({ apps: configured })
|
|
287
287
|
|
|
288
288
|
const routes = {}
|
|
289
289
|
const router = {
|
|
290
290
|
get: (path, handler) => {
|
|
291
291
|
routes[path] = handler
|
|
292
|
-
}
|
|
292
|
+
},
|
|
293
|
+
post: () => {}
|
|
293
294
|
}
|
|
294
295
|
plugin.registerWithRouter(router)
|
|
295
296
|
|
|
@@ -305,63 +306,196 @@ describe('auto-discovery on start', () => {
|
|
|
305
306
|
assert.ok(!resolved.apps.some((a) => a.url === '/@signalk/freeboard-sk/'))
|
|
306
307
|
assert.ok(resolved.apps.some((a) => a.url === '/@mxtommy/kip/'))
|
|
307
308
|
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('package.json', () => {
|
|
312
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'))
|
|
313
|
+
|
|
314
|
+
it('has signalk-node-server-plugin keyword', () => {
|
|
315
|
+
assert.ok(pkg.keywords.includes('signalk-node-server-plugin'))
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('has signalk-webapp keyword', () => {
|
|
319
|
+
assert.ok(pkg.keywords.includes('signalk-webapp'))
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('has signalk-plugin-configurator keyword', () => {
|
|
323
|
+
assert.ok(pkg.keywords.includes('signalk-plugin-configurator'))
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('main points to plugin/index.js', () => {
|
|
327
|
+
assert.equal(pkg.main, 'plugin/index.js')
|
|
328
|
+
assert.ok(fs.existsSync(path.join(__dirname, '..', pkg.main)))
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
describe('night mode PUT handler', () => {
|
|
333
|
+
it('registers PUT handler when showNightModeButton is true', () => {
|
|
334
|
+
let registered = false
|
|
335
|
+
const app = createMockApp()
|
|
336
|
+
app.registerPutHandler = (context, path, callback, sourceId) => {
|
|
337
|
+
registered = true
|
|
338
|
+
assert.equal(context, 'vessels.self')
|
|
339
|
+
assert.equal(path, 'environment.mode')
|
|
340
|
+
assert.equal(sourceId, 'signalk-app-dock')
|
|
341
|
+
}
|
|
308
342
|
|
|
309
|
-
it('uses discovered label/icon when config overrides are empty', () => {
|
|
310
|
-
const app = createMockApp(MOCK_WEBAPPS)
|
|
311
|
-
app.savePluginOptions = (_, cb) => cb(null)
|
|
312
343
|
const plugin = pluginFactory(app)
|
|
344
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
345
|
+
globalThis.setTimeout = (fn) => fn()
|
|
346
|
+
try {
|
|
347
|
+
plugin.start({ showNightModeButton: true, apps: [] })
|
|
348
|
+
} finally {
|
|
349
|
+
globalThis.setTimeout = originalSetTimeout
|
|
350
|
+
}
|
|
351
|
+
assert.ok(registered)
|
|
352
|
+
})
|
|
313
353
|
|
|
314
|
-
|
|
354
|
+
it('does not register PUT handler when showNightModeButton is false', () => {
|
|
355
|
+
let registered = false
|
|
356
|
+
const app = createMockApp()
|
|
357
|
+
app.registerPutHandler = () => {
|
|
358
|
+
registered = true
|
|
359
|
+
}
|
|
315
360
|
|
|
361
|
+
const plugin = pluginFactory(app)
|
|
316
362
|
const originalSetTimeout = globalThis.setTimeout
|
|
317
363
|
globalThis.setTimeout = (fn) => fn()
|
|
318
364
|
try {
|
|
319
|
-
plugin.start({ apps:
|
|
365
|
+
plugin.start({ showNightModeButton: false, apps: [] })
|
|
320
366
|
} finally {
|
|
321
367
|
globalThis.setTimeout = originalSetTimeout
|
|
322
368
|
}
|
|
369
|
+
assert.equal(registered, false)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('PUT handler accepts valid mode values and emits delta', () => {
|
|
373
|
+
const app = createMockApp()
|
|
374
|
+
let handlerCallback
|
|
375
|
+
let emittedDelta = null
|
|
376
|
+
app.registerPutHandler = (ctx, path, cb) => {
|
|
377
|
+
handlerCallback = cb
|
|
378
|
+
}
|
|
379
|
+
app.handleMessage = (id, delta) => {
|
|
380
|
+
emittedDelta = delta
|
|
381
|
+
}
|
|
323
382
|
|
|
324
|
-
const
|
|
383
|
+
const plugin = pluginFactory(app)
|
|
384
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
385
|
+
globalThis.setTimeout = (fn) => fn()
|
|
386
|
+
try {
|
|
387
|
+
plugin.start({ showNightModeButton: true, apps: [] })
|
|
388
|
+
} finally {
|
|
389
|
+
globalThis.setTimeout = originalSetTimeout
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const result = handlerCallback('vessels.self', 'environment.mode', 'night', () => {})
|
|
393
|
+
assert.equal(result.state, 'COMPLETED')
|
|
394
|
+
assert.equal(result.statusCode, 200)
|
|
395
|
+
assert.equal(emittedDelta.updates[0].values[0].path, 'environment.mode')
|
|
396
|
+
assert.equal(emittedDelta.updates[0].values[0].value, 'night')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('PUT handler rejects invalid mode values', () => {
|
|
400
|
+
const app = createMockApp()
|
|
401
|
+
let handlerCallback
|
|
402
|
+
app.registerPutHandler = (ctx, path, cb) => {
|
|
403
|
+
handlerCallback = cb
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const plugin = pluginFactory(app)
|
|
407
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
408
|
+
globalThis.setTimeout = (fn) => fn()
|
|
409
|
+
try {
|
|
410
|
+
plugin.start({ showNightModeButton: true, apps: [] })
|
|
411
|
+
} finally {
|
|
412
|
+
globalThis.setTimeout = originalSetTimeout
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const result = handlerCallback('vessels.self', 'environment.mode', 'invalid', () => {})
|
|
416
|
+
assert.equal(result.statusCode, 400)
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
describe('dismiss-tour endpoint', () => {
|
|
421
|
+
it('saves tourDismissed: true and responds', (t, done) => {
|
|
422
|
+
const app = createMockApp()
|
|
423
|
+
let saved = null
|
|
424
|
+
app.savePluginOptions = (opts, cb) => {
|
|
425
|
+
saved = opts
|
|
426
|
+
cb(null)
|
|
427
|
+
}
|
|
428
|
+
const plugin = pluginFactory(app)
|
|
429
|
+
plugin.start({ apps: [], position: 'left' })
|
|
430
|
+
|
|
431
|
+
const routes = { get: {}, post: {} }
|
|
325
432
|
const router = {
|
|
326
433
|
get: (path, handler) => {
|
|
327
|
-
routes[path] = handler
|
|
434
|
+
routes.get[path] = handler
|
|
435
|
+
},
|
|
436
|
+
post: (path, handler) => {
|
|
437
|
+
routes.post[path] = handler
|
|
328
438
|
}
|
|
329
439
|
}
|
|
330
440
|
plugin.registerWithRouter(router)
|
|
331
441
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
442
|
+
const res = {
|
|
443
|
+
json: (data) => {
|
|
444
|
+
assert.equal(data.tourDismissed, true)
|
|
445
|
+
assert.equal(saved.tourDismissed, true)
|
|
446
|
+
assert.equal(saved.position, 'left')
|
|
447
|
+
done()
|
|
339
448
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
assert.equal(kip.label, 'KIP Instrument MFD')
|
|
343
|
-
assert.equal(kip.icon, '/@mxtommy/kip/assets/icon-72x72.png')
|
|
449
|
+
}
|
|
450
|
+
routes.post['/dismiss-tour']({}, res)
|
|
344
451
|
})
|
|
345
452
|
})
|
|
346
453
|
|
|
347
|
-
describe('
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
454
|
+
describe('mode endpoint', () => {
|
|
455
|
+
it('returns day when no mode is set', (t, done) => {
|
|
456
|
+
const app = createMockApp()
|
|
457
|
+
const plugin = pluginFactory(app)
|
|
458
|
+
const routes = {}
|
|
459
|
+
const router = {
|
|
460
|
+
get: (path, handler) => {
|
|
461
|
+
routes[path] = handler
|
|
462
|
+
},
|
|
463
|
+
post: () => {}
|
|
464
|
+
}
|
|
465
|
+
plugin.registerWithRouter(router)
|
|
353
466
|
|
|
354
|
-
|
|
355
|
-
|
|
467
|
+
const res = {
|
|
468
|
+
json: (data) => {
|
|
469
|
+
assert.equal(data.value, 'day')
|
|
470
|
+
done()
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
routes['/mode']({}, res)
|
|
356
474
|
})
|
|
357
475
|
|
|
358
|
-
it('
|
|
359
|
-
|
|
360
|
-
|
|
476
|
+
it('returns current mode when set', (t, done) => {
|
|
477
|
+
const app = createMockApp()
|
|
478
|
+
app.getSelfPath = (path) => {
|
|
479
|
+
if (path === 'environment.mode') return { value: 'night' }
|
|
480
|
+
return null
|
|
481
|
+
}
|
|
482
|
+
const plugin = pluginFactory(app)
|
|
483
|
+
const routes = {}
|
|
484
|
+
const router = {
|
|
485
|
+
get: (path, handler) => {
|
|
486
|
+
routes[path] = handler
|
|
487
|
+
},
|
|
488
|
+
post: () => {}
|
|
489
|
+
}
|
|
490
|
+
plugin.registerWithRouter(router)
|
|
361
491
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
492
|
+
const res = {
|
|
493
|
+
json: (data) => {
|
|
494
|
+
assert.equal(data.value, 'night')
|
|
495
|
+
done()
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
routes['/mode']({}, res)
|
|
365
499
|
})
|
|
366
500
|
})
|
|
367
501
|
|