@signalk/app-dock 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalk/app-dock",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "macOS-style configurable app dock for switching between SignalK webapps on touch screens",
5
5
  "repository": {
6
6
  "url": "https://github.com/SignalK/app-dock"
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
- setTimeout(() => {
43
- const discovered = getWebapps()
44
- if (discovered.length === 0) return
45
-
46
- const configured = settings.apps || []
47
- const configuredUrls = new Set(configured.map((a) => a.url))
48
-
49
- const merged = [...configured]
50
- let added = false
51
- for (const w of discovered) {
52
- if (!configuredUrls.has(w.url)) {
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: {
@@ -157,7 +181,7 @@ module.exports = (app) => {
157
181
  type: 'boolean',
158
182
  title: 'Show night/day mode toggle',
159
183
  description: 'Adds a sun/moon button to the dock that toggles environment.mode',
160
- default: false
184
+ default: true
161
185
  },
162
186
 
163
187
  showExitButton: {
@@ -167,6 +191,14 @@ module.exports = (app) => {
167
191
  default: false
168
192
  },
169
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
+
170
202
  apps: {
171
203
  type: 'array',
172
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,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,u]=(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,I]=(0,a.useState)(!1),[T,F]=(0,a.useState)(!1),[A,M]=(0,a.useState)(null),[N,W]=(0,a.useState)(null),$=(0,a.useRef)(s);$.current=s;const _=(0,a.useCallback)(e=>({position:g,triggerCorner:p,iframeMode:b,iconSize:h,magnification:y,magnificationScale:E,showNightModeButton:x,showExitButton:C,apps:e}),[g,p,b,h,y,E,x,C]),O=(0,a.useCallback)(()=>{t(_(s)),R("Saved!"),I(!1)},[s,_,t]),U=()=>W(null),j=()=>{M(null),W(null)},L=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:g,onChange:u,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,...T?{opacity:.6}:{}},onClick:async()=>{F(!0),R(""),I(!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?"),I(!0),void F(!1);const n=$.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.`),I(!1)}catch(e){R("Discovery failed: "+e.message),I(!0)}F(!1)},disabled:T},T?"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=>M(e))(t),onDragOver:e=>((e,t)=>{e.preventDefault(),W(t)})(e,t),onDragLeave:U,onDrop:e=>((e,t)=>{if(e.preventDefault(),W(null),null!==A&&A!==t){const e=[...s],[n]=e.splice(A,1);e.splice(t,0,n),d(e)}M(null)})(e,t),onDragEnd:j,style:{...l.item,...!1===e.enabled?l.itemDisabled:{},...N===t?l.dragOver:{},...A===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")))),(L.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"}})),L.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},onClick:O},"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)))}}}]);
@@ -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="#1e3a5f"/>
5
- <stop offset="100%" stop-color="#0a1628"/>
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.1" stroke="#ffffff" stroke-opacity="0.2" stroke-width="0.5"/>
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: 20;
123
+ z-index: 40;
124
124
  opacity: 0;
125
125
  pointer-events: none;
126
126
  transition:
@@ -447,3 +447,115 @@ body {
447
447
  transform: rotate(360deg);
448
448
  }
449
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
@@ -420,6 +420,7 @@
420
420
 
421
421
  function hideDock() {
422
422
  if (!dockVisible) return
423
+ if ($tourOverlay && $tourOverlay.classList.contains('visible')) return
423
424
  dockVisible = false
424
425
  $dock.classList.remove('visible')
425
426
  $backdrop.classList.remove('visible')
@@ -561,6 +562,83 @@
561
562
  setInterval(fetchCurrentMode, 5000)
562
563
  }
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
+
564
642
  const autostartIdx = cfg.apps.findIndex((a) => a.autostart)
565
643
  if (autostartIdx >= 0) {
566
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: {
@@ -186,12 +186,20 @@ export default function PluginConfigurationPanel({ configuration, save }) {
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,
@@ -216,10 +224,25 @@ export default function PluginConfigurationPanel({ configuration, save }) {
216
224
  ]
217
225
  )
218
226
 
219
- const doSave = useCallback(() => {
220
- save(buildConfig(apps))
221
- setStatus('Saved!')
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 () => {
@@ -498,9 +521,14 @@ export default function PluginConfigurationPanel({ configuration, save }) {
498
521
  )}
499
522
 
500
523
  <div style={S.actions}>
501
- <button style={{ ...S.btn, ...S.btnSave }} onClick={doSave}>
502
- Save Configuration
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'}
503
530
  </button>
531
+ {status && <div style={{ ...S.status, color: statusError ? '#ef4444' : '#10b981' }}>{status}</div>}
504
532
  </div>
505
533
  </div>
506
534
  )
@@ -78,11 +78,11 @@ describe('schema', () => {
78
78
  assert.deepEqual(items.required, ['url'])
79
79
  })
80
80
 
81
- it('defines showNightModeButton with default false', () => {
81
+ it('defines showNightModeButton with default true', () => {
82
82
  const plugin = pluginFactory(createMockApp())
83
83
  const prop = plugin.schema.properties.showNightModeButton
84
84
  assert.equal(prop.type, 'boolean')
85
- assert.equal(prop.default, false)
85
+ assert.equal(prop.default, true)
86
86
  })
87
87
 
88
88
  it('defines showExitButton with default false', () => {
@@ -91,6 +91,18 @@ describe('schema', () => {
91
91
  assert.equal(prop.type, 'boolean')
92
92
  assert.equal(prop.default, false)
93
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
+ })
94
106
  })
95
107
 
96
108
  describe('webapp discovery', () => {
@@ -101,7 +113,8 @@ describe('webapp discovery', () => {
101
113
  const router = {
102
114
  get: (path, handler) => {
103
115
  routes[path] = handler
104
- }
116
+ },
117
+ post: () => {}
105
118
  }
106
119
  plugin.registerWithRouter(router)
107
120
 
@@ -123,7 +136,8 @@ describe('webapp discovery', () => {
123
136
  const router = {
124
137
  get: (path, handler) => {
125
138
  routes[path] = handler
126
- }
139
+ },
140
+ post: () => {}
127
141
  }
128
142
  plugin.registerWithRouter(router)
129
143
 
@@ -146,7 +160,8 @@ describe('webapp discovery', () => {
146
160
  const router = {
147
161
  get: (path, handler) => {
148
162
  routes[path] = handler
149
- }
163
+ },
164
+ post: () => {}
150
165
  }
151
166
  plugin.registerWithRouter(router)
152
167
 
@@ -169,7 +184,8 @@ describe('webapp discovery', () => {
169
184
  const router = {
170
185
  get: (path, handler) => {
171
186
  routes[path] = handler
172
- }
187
+ },
188
+ post: () => {}
173
189
  }
174
190
  plugin.registerWithRouter(router)
175
191
 
@@ -193,7 +209,8 @@ describe('settings endpoint', () => {
193
209
  const router = {
194
210
  get: (path, handler) => {
195
211
  routes[path] = handler
196
- }
212
+ },
213
+ post: () => {}
197
214
  }
198
215
  plugin.registerWithRouter(router)
199
216
 
@@ -209,9 +226,9 @@ describe('settings endpoint', () => {
209
226
  })
210
227
  })
211
228
 
212
- describe('auto-discovery on start', () => {
213
- it('merges discovered webapps into configured apps after delay', (t, done) => {
214
- const app = createMockApp(MOCK_WEBAPPS)
229
+ describe('default apps seeding', () => {
230
+ it('seeds default apps when config has no apps key', () => {
231
+ const app = createMockApp()
215
232
  let savedOptions = null
216
233
  app.savePluginOptions = (opts, cb) => {
217
234
  savedOptions = opts
@@ -219,73 +236,46 @@ describe('auto-discovery on start', () => {
219
236
  }
220
237
 
221
238
  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
- }
239
+ plugin.start({})
230
240
 
231
241
  assert.ok(savedOptions)
232
- assert.ok(savedOptions.apps.length >= 2)
233
- assert.ok(savedOptions.apps.some((a) => a.url === '/@signalk/freeboard-sk/'))
234
- assert.ok(savedOptions.apps.some((a) => a.url === '/@mxtommy/kip/'))
235
- done()
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')
236
248
  })
237
249
 
238
- it('does not duplicate already-configured apps', (t, done) => {
239
- const app = createMockApp(MOCK_WEBAPPS)
240
- let savedOptions = null
241
- app.savePluginOptions = (opts, cb) => {
242
- savedOptions = opts
243
- 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
244
255
  }
245
256
 
246
257
  const plugin = pluginFactory(app)
247
- const existing = [{ enabled: true, url: '/@signalk/freeboard-sk/', label: 'FB', icon: '', color: '' }]
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
- }
258
+ plugin.start({ apps: [] })
256
259
 
257
- const fbEntries = savedOptions.apps.filter((a) => a.url === '/@signalk/freeboard-sk/')
258
- assert.equal(fbEntries.length, 1)
259
- assert.equal(fbEntries[0].label, 'FB')
260
- done()
260
+ assert.equal(saveCalled, false)
261
261
  })
262
262
 
263
- it('skips save when no new apps are discovered', () => {
264
- const app = createMockApp(MOCK_WEBAPPS)
263
+ it('preserves existing configured apps', () => {
264
+ const app = createMockApp()
265
265
  let saveCalled = false
266
266
  app.savePluginOptions = () => {
267
267
  saveCalled = true
268
268
  }
269
269
 
270
270
  const plugin = pluginFactory(app)
271
- const allConfigured = [
272
- { enabled: true, url: '/@signalk/freeboard-sk/', label: '', icon: '', color: '' },
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
- }
271
+ const existing = [{ enabled: true, url: '/custom-app/', label: 'Custom', icon: '', color: '' }]
272
+ plugin.start({ apps: existing })
283
273
 
284
274
  assert.equal(saveCalled, false)
285
275
  })
286
276
 
287
277
  it('respects enabled:false when resolving apps', () => {
288
- const app = createMockApp(MOCK_WEBAPPS)
278
+ const app = createMockApp()
289
279
  app.savePluginOptions = (_, cb) => cb(null)
290
280
  const plugin = pluginFactory(app)
291
281
 
@@ -293,20 +283,14 @@ describe('auto-discovery on start', () => {
293
283
  { enabled: false, url: '/@signalk/freeboard-sk/', label: '', icon: '', color: '' },
294
284
  { enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
295
285
  ]
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
- }
286
+ plugin.start({ apps: configured })
304
287
 
305
288
  const routes = {}
306
289
  const router = {
307
290
  get: (path, handler) => {
308
291
  routes[path] = handler
309
- }
292
+ },
293
+ post: () => {}
310
294
  }
311
295
  plugin.registerWithRouter(router)
312
296
 
@@ -322,43 +306,6 @@ describe('auto-discovery on start', () => {
322
306
  assert.ok(!resolved.apps.some((a) => a.url === '/@signalk/freeboard-sk/'))
323
307
  assert.ok(resolved.apps.some((a) => a.url === '/@mxtommy/kip/'))
324
308
  })
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
309
  })
363
310
 
364
311
  describe('package.json', () => {
@@ -470,6 +417,40 @@ describe('night mode PUT handler', () => {
470
417
  })
471
418
  })
472
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: {} }
432
+ const router = {
433
+ get: (path, handler) => {
434
+ routes.get[path] = handler
435
+ },
436
+ post: (path, handler) => {
437
+ routes.post[path] = handler
438
+ }
439
+ }
440
+ plugin.registerWithRouter(router)
441
+
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()
448
+ }
449
+ }
450
+ routes.post['/dismiss-tour']({}, res)
451
+ })
452
+ })
453
+
473
454
  describe('mode endpoint', () => {
474
455
  it('returns day when no mode is set', (t, done) => {
475
456
  const app = createMockApp()
@@ -478,7 +459,8 @@ describe('mode endpoint', () => {
478
459
  const router = {
479
460
  get: (path, handler) => {
480
461
  routes[path] = handler
481
- }
462
+ },
463
+ post: () => {}
482
464
  }
483
465
  plugin.registerWithRouter(router)
484
466
 
@@ -502,7 +484,8 @@ describe('mode endpoint', () => {
502
484
  const router = {
503
485
  get: (path, handler) => {
504
486
  routes[path] = handler
505
- }
487
+ },
488
+ post: () => {}
506
489
  }
507
490
  plugin.registerWithRouter(router)
508
491