@signalk/app-dock 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,6 +37,26 @@ Tip: double-tapping a clickable element in the underlying webapp will activate t
37
37
 
38
38
  Config changes made in **Plugin Config** apply automatically within ~5 seconds: structural changes (position, icon size, etc.) reload the dock page; changes to the app list hot-update in place.
39
39
 
40
+ ## Apps on another origin
41
+
42
+ The dock notices a double-tap by listening inside each app's page, and a browser allows that only for pages on the same origin (scheme, host and port) as the dock. A page from another port or host is out of reach: the app itself, a page the app moves its frame to (such as a radar display opened directly on its own server), or one the app shows in a frame of its own. While any such page in the app on screen does not forward its taps, a small tab at the dock's edge opens the dock with a single tap.
43
+
44
+ An app on another origin can bring back double-tap by forwarding its pointerdowns to the dock:
45
+
46
+ ```js
47
+ if (window.self !== window.top) {
48
+ document.addEventListener(
49
+ 'pointerdown',
50
+ (e) => {
51
+ window.top.postMessage({ type: 'signalk-app-dock:pointerdown', x: e.clientX, y: e.clientY }, '*')
52
+ },
53
+ { passive: true, capture: true }
54
+ )
55
+ }
56
+ ```
57
+
58
+ The dock accepts these messages only from a page of the app on screen that it cannot listen to itself, or a frame inside that page, and all they do is count towards a double-tap. The tab goes away once every such page has sent one. A page that loads again starts over: the tab is back until its first tap.
59
+
40
60
  ## Install as an app
41
61
 
42
62
  The dock ships a web app manifest, so iOS and Android can install it to the home screen and launch it like a native app -- no Safari/Chrome chrome, no URL bar.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalk/app-dock",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
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"
@@ -22,6 +22,11 @@
22
22
  ]
23
23
  },
24
24
  "main": "plugin/index.js",
25
+ "files": [
26
+ "plugin/",
27
+ "public/",
28
+ "docs/screenshots/"
29
+ ],
25
30
  "scripts": {
26
31
  "build:config": "webpack",
27
32
  "prepublishOnly": "webpack",
Binary file
@@ -5,10 +5,8 @@
5
5
  <stop offset="100%" stop-color="#2563eb"/>
6
6
  </linearGradient>
7
7
  </defs>
8
- <!-- White base -->
9
- <rect width="72" height="72" fill="#ffffff"/>
10
- <!-- Background -->
11
- <rect width="72" height="72" rx="16" fill="url(#bg)"/>
8
+ <!-- Background, full bleed: whatever displays the icon rounds the corners -->
9
+ <rect width="72" height="72" fill="url(#bg)"/>
12
10
  <!-- Dock pill -->
13
11
  <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"/>
14
12
  <!-- Dock icons - 3 squares in the pill -->
package/public/dock.css CHANGED
@@ -99,6 +99,68 @@ body {
99
99
  }
100
100
  }
101
101
 
102
+ /* ─── Handle over an app on another origin ─────────────────────────────────── */
103
+ /* The padding is the touch target; the bar itself is drawn by ::before. */
104
+ #dock-handle {
105
+ position: fixed;
106
+ z-index: 5;
107
+ display: none;
108
+ padding: 12px 18px;
109
+ border: none;
110
+ background: transparent;
111
+ -webkit-appearance: none;
112
+ appearance: none;
113
+ cursor: pointer;
114
+ touch-action: manipulation;
115
+ -webkit-tap-highlight-color: transparent;
116
+ }
117
+
118
+ #dock-handle.visible {
119
+ display: block;
120
+ }
121
+
122
+ #dock-handle::before {
123
+ content: '';
124
+ display: block;
125
+ width: 56px;
126
+ height: 6px;
127
+ border-radius: 3px;
128
+ background: rgba(255, 255, 255, 0.6);
129
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
130
+ }
131
+
132
+ #dock-handle.pos-bottom {
133
+ bottom: env(safe-area-inset-bottom, 0px);
134
+ left: 50%;
135
+ transform: translateX(-50%);
136
+ }
137
+
138
+ #dock-handle.pos-top {
139
+ top: env(safe-area-inset-top, 0px);
140
+ left: 50%;
141
+ transform: translateX(-50%);
142
+ }
143
+
144
+ #dock-handle.pos-left {
145
+ left: env(safe-area-inset-left, 0px);
146
+ top: 50%;
147
+ transform: translateY(-50%);
148
+ padding: 18px 12px;
149
+ }
150
+
151
+ #dock-handle.pos-right {
152
+ right: env(safe-area-inset-right, 0px);
153
+ top: 50%;
154
+ transform: translateY(-50%);
155
+ padding: 18px 12px;
156
+ }
157
+
158
+ #dock-handle.pos-left::before,
159
+ #dock-handle.pos-right::before {
160
+ width: 6px;
161
+ height: 56px;
162
+ }
163
+
102
164
  /* ─── Backdrop ──────────────────────────────────────────────────────────────── */
103
165
  #dock-backdrop {
104
166
  position: fixed;
package/public/dock.js CHANGED
@@ -164,6 +164,7 @@
164
164
  const $loadingLabel = document.getElementById('loading-label')
165
165
  const $idleHint = document.getElementById('idle-hint')
166
166
  const $idleHintText = document.getElementById('idle-hint-text')
167
+ const $dockHandle = document.getElementById('dock-handle')
167
168
 
168
169
  // ─── State ───────────────────────────────────────────────────────────────────
169
170
  let dockVisible = false
@@ -250,6 +251,7 @@
250
251
  const pos = cfg.position
251
252
  $dock.classList.add(`pos-${pos}`)
252
253
  $dockInner.classList.add(`align-${pos}`)
254
+ $dockHandle.classList.add(`pos-${pos}`)
253
255
 
254
256
  const isVertical = pos === 'left' || pos === 'right'
255
257
  if (isVertical) $dockInner.classList.add('layout-vertical')
@@ -576,6 +578,7 @@
576
578
  dockShownAt = Date.now()
577
579
  $dock.classList.add('visible')
578
580
  $backdrop.classList.add('visible')
581
+ updateHandle()
579
582
  }
580
583
 
581
584
  function hideDock() {
@@ -585,6 +588,7 @@
585
588
  $dock.classList.remove('visible')
586
589
  $backdrop.classList.remove('visible')
587
590
  resetMagnification()
591
+ updateHandle()
588
592
  }
589
593
 
590
594
  $backdrop.addEventListener('click', (e) => {
@@ -649,6 +653,7 @@
649
653
  }
650
654
 
651
655
  iframes[app.url].classList.add('active')
656
+ updateHandle()
652
657
  hideDock()
653
658
  }
654
659
 
@@ -665,6 +670,9 @@
665
670
  let lastTapTime = 0
666
671
  let lastTapX = 0
667
672
  let lastTapY = 0
673
+ // Coordinates are in the dock's viewport unless a tap names another space:
674
+ // two taps pair up only within the same one.
675
+ let lastTapSpace = null
668
676
 
669
677
  function triggerDock() {
670
678
  if (DEBUG) console.log('[Dock] triggerDock called, dockVisible=', dockVisible)
@@ -679,13 +687,13 @@
679
687
 
680
688
  const DEBUG = new URLSearchParams(location.search).has('debugDock')
681
689
 
682
- function handleTap(clientX, clientY) {
690
+ function handleTap(clientX, clientY, space = null) {
683
691
  if (dockVisible) return
684
692
  const now = Date.now()
685
693
  const dx = clientX - lastTapX
686
694
  const dy = clientY - lastTapY
687
695
  const dist = Math.round(Math.sqrt(dx * dx + dy * dy))
688
- const close = dist <= DOUBLE_TAP_SLOP
696
+ const close = dist <= DOUBLE_TAP_SLOP && space === lastTapSpace
689
697
  const gap = now - lastTapTime
690
698
  const match = gap < DOUBLE_TAP_MS && close
691
699
  if (DEBUG) {
@@ -698,6 +706,7 @@
698
706
  lastTapTime = now
699
707
  lastTapX = clientX
700
708
  lastTapY = clientY
709
+ lastTapSpace = space
701
710
  }
702
711
  }
703
712
 
@@ -756,12 +765,14 @@
756
765
 
757
766
  function attachToIframe(frame) {
758
767
  const wire = () => {
759
- let doc
768
+ let doc = null
760
769
  try {
761
770
  doc = frame.contentDocument
762
771
  } catch {
763
- return // cross-origin; can't reach
772
+ // cross-origin; can't reach
764
773
  }
774
+ const app = appFrameOf(frame)
775
+ if (app) noteFrameReach(app, frame, doc !== null)
765
776
  if (!doc || attachedDocs.has(doc)) return
766
777
  attachedDocs.add(doc)
767
778
  attachTapListener(doc, frame)
@@ -786,11 +797,137 @@
786
797
  const nested = n.querySelectorAll && n.querySelectorAll('iframe')
787
798
  if (nested) nested.forEach((f) => attachToIframe(f))
788
799
  })
800
+ // A removed frame may have been the one keeping the handle up.
801
+ if (r.removedNodes.length > 0) updateHandle()
789
802
  }
790
803
  })
791
804
  obs.observe(root, { childList: true, subtree: true })
792
805
  }
793
806
 
807
+ // ─── Apps on another origin ──────────────────────────────────────────────────
808
+ // The listeners above cannot reach a page from another origin, so a
809
+ // double-tap over one goes unseen. That page may be the app itself or a
810
+ // frame inside it. Such a page can forward its pointerdowns instead, and
811
+ // while any page in the active app is out of reach and not forwarding, a
812
+ // tab at the dock's edge opens the dock with a single tap.
813
+ //
814
+ // A forwarded pointerdown is a message posted to the dock's window:
815
+ // window.top.postMessage({ type: 'signalk-app-dock:pointerdown', x: e.clientX, y: e.clientY }, '*')
816
+ // It counts only from inside a frame of the active app that the listeners
817
+ // cannot reach, so no tap is counted twice, and all it can do is feed the
818
+ // double-tap detection.
819
+ const FORWARDED_POINTERDOWN = 'signalk-app-dock:pointerdown'
820
+ const FORWARD_COALESCE_MS = 50
821
+ // Per app frame, the frames in it (itself included) the listeners cannot
822
+ // reach, and which of those have sent a pointerdown of their own since they
823
+ // loaded.
824
+ const unreachableFrames = new WeakMap()
825
+ const forwardingFrames = new WeakSet()
826
+ let lastForwardedAt = 0
827
+
828
+ // The app frame a frame belongs to: the frame itself, or the one holding
829
+ // the document it sits in, found through same-origin parents.
830
+ function appFrameOf(frame) {
831
+ let f = frame
832
+ while (f && f.parentElement !== $iframeContainer) {
833
+ const win = f.ownerDocument && f.ownerDocument.defaultView
834
+ f = win ? win.frameElement : null
835
+ }
836
+ return f
837
+ }
838
+
839
+ // Runs on every load of a frame in an app. A new document starts over:
840
+ // whether it forwards is not known until it does, and an app frame's new
841
+ // document brings its own frames.
842
+ function noteFrameReach(app, frame, reachable) {
843
+ forwardingFrames.delete(frame)
844
+ let frames = unreachableFrames.get(app)
845
+ if (frame === app || !frames) {
846
+ frames = new Set()
847
+ unreachableFrames.set(app, frames)
848
+ }
849
+ if (reachable) frames.delete(frame)
850
+ else frames.add(frame)
851
+ updateHandle()
852
+ }
853
+
854
+ // Frames removed from their document, or left in one that has been
855
+ // navigated away, are dropped as they are found.
856
+ function liveUnreachableFrames(app) {
857
+ const frames = (app && unreachableFrames.get(app)) || new Set()
858
+ for (const f of frames) {
859
+ if (!f.isConnected || !f.ownerDocument.defaultView) frames.delete(f)
860
+ }
861
+ return frames
862
+ }
863
+
864
+ function updateHandle() {
865
+ const app = $iframeContainer.querySelector('iframe.active')
866
+ let show = false
867
+ if (!dockVisible) {
868
+ for (const f of liveUnreachableFrames(app)) {
869
+ if (!forwardingFrames.has(f)) show = true
870
+ }
871
+ }
872
+ $dockHandle.classList.toggle('visible', show)
873
+ }
874
+
875
+ // Where a frame's content starts in the dock's viewport, adding up the
876
+ // frames it sits in.
877
+ function offsetInDock(frame) {
878
+ let x = 0
879
+ let y = 0
880
+ for (let f = frame; f; ) {
881
+ const r = f.getBoundingClientRect()
882
+ x += r.left
883
+ y += r.top
884
+ if (f.parentElement === $iframeContainer) break
885
+ const win = f.ownerDocument.defaultView
886
+ f = win ? win.frameElement : null
887
+ }
888
+ return { x, y }
889
+ }
890
+
891
+ function isInsideFrame(source, frameWindow) {
892
+ for (let w = source; w; w = w.parent) {
893
+ if (w === frameWindow) return true
894
+ if (w === w.parent) return false
895
+ }
896
+ return false
897
+ }
898
+
899
+ window.addEventListener('message', (e) => {
900
+ const data = e.data
901
+ if (!data || data.type !== FORWARDED_POINTERDOWN) return
902
+ if (!Number.isFinite(data.x) || !Number.isFinite(data.y)) return
903
+ const app = $iframeContainer.querySelector('iframe.active')
904
+ if (!app || !e.source) return
905
+ let from = null
906
+ for (const f of liveUnreachableFrames(app)) {
907
+ if (isInsideFrame(e.source, f.contentWindow)) from = f
908
+ }
909
+ if (!from) return
910
+ // A frame inside the page forwarding says nothing about taps on the page
911
+ // around it, so only the page's own message stops the handle.
912
+ if (e.source === from.contentWindow) {
913
+ forwardingFrames.add(from)
914
+ updateHandle()
915
+ }
916
+ const now = Date.now()
917
+ if (now - lastForwardedAt < FORWARD_COALESCE_MS) return
918
+ lastForwardedAt = now
919
+ // A frame inside the page sits somewhere the dock cannot see, so its
920
+ // taps keep their own coordinates and pair up only with each other.
921
+ if (e.source === from.contentWindow) {
922
+ const o = offsetInDock(from)
923
+ handleTap(data.x + o.x, data.y + o.y)
924
+ } else {
925
+ handleTap(data.x, data.y, e.source)
926
+ }
927
+ })
928
+
929
+ $dockHandle.addEventListener('click', triggerDock)
930
+
794
931
  attachTapListener(document, null)
795
932
  attachedDocs.add(document)
796
933
  scanAndObserveDoc(document)
package/public/index.html CHANGED
@@ -28,6 +28,10 @@
28
28
  <div id="idle-hint-text"></div>
29
29
  </div>
30
30
 
31
+ <!-- Tab that opens the dock over an app on another origin, where a
32
+ double-tap cannot be detected -->
33
+ <button id="dock-handle" type="button" aria-label="Open the dock"></button>
34
+
31
35
  <!-- Dimming overlay shown when dock is open; tap to close -->
32
36
  <div id="dock-backdrop"></div>
33
37
 
package/.gitattributes DELETED
@@ -1,5 +0,0 @@
1
- # Force LF line endings on checkout regardless of the host's
2
- # core.autocrlf setting. Prettier/eslint enforce LF; without this,
3
- # Windows CI runners check out CRLF and `prettier --check` reports
4
- # every line as "Delete `␍`".
5
- * text=auto eol=lf
@@ -1,11 +0,0 @@
1
- name: SignalK Plugin CI
2
-
3
- on:
4
- push:
5
- branches: [main, master]
6
- pull_request:
7
- branches: [main, master]
8
-
9
- jobs:
10
- test:
11
- uses: SignalK/signalk-server/.github/workflows/plugin-ci.yml@master
@@ -1,57 +0,0 @@
1
- name: 'Release on tag'
2
-
3
- on:
4
- push:
5
- tags:
6
- - 'v*'
7
-
8
- concurrency:
9
- group: release-${{ github.ref }}
10
- cancel-in-progress: false
11
-
12
- jobs:
13
- release:
14
- permissions:
15
- contents: write
16
- runs-on: ubuntu-latest
17
- timeout-minutes: 10
18
- steps:
19
- - name: Create Release
20
- uses: softprops/action-gh-release@v3
21
- with:
22
- tag_name: ${{ github.ref_name }}
23
- name: ${{ github.ref_name }}
24
- generate_release_notes: true
25
- prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') }}
26
- env:
27
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28
-
29
- publish:
30
- needs: release
31
- permissions:
32
- id-token: write
33
- contents: read
34
- runs-on: ubuntu-latest
35
- timeout-minutes: 10
36
- steps:
37
- - uses: actions/checkout@v6
38
-
39
- - uses: actions/setup-node@v6
40
- with:
41
- node-version: '24.x'
42
- registry-url: 'https://registry.npmjs.org'
43
-
44
- - run: npm ci
45
-
46
- - name: Publish to npm
47
- run: |
48
- tag="${GITHUB_REF#refs/tags/}"
49
- if [[ "$tag" == *alpha* ]]; then
50
- npm publish --provenance --access public --tag alpha
51
- elif [[ "$tag" == *beta* ]]; then
52
- npm publish --provenance --access public --tag beta
53
- elif [[ "$tag" == *rc* ]]; then
54
- npm publish --provenance --access public --tag rc
55
- else
56
- npm publish --provenance --access public
57
- fi
@@ -1,42 +0,0 @@
1
- # Marks issues labeled "awaiting response" as stale after 30 days of inactivity
2
- # and closes them 30 days after that (60 days total). Issues labeled
3
- # "good first task" or "help wanted" are exempt. Pull requests are never
4
- # touched. The workflow runs daily and can also be triggered manually.
5
- name: Stale issues
6
-
7
- on:
8
- schedule:
9
- - cron: '17 1 * * *'
10
- workflow_dispatch:
11
-
12
- permissions:
13
- contents: read
14
- issues: write
15
-
16
- concurrency:
17
- group: stale
18
- cancel-in-progress: false
19
-
20
- jobs:
21
- stale:
22
- runs-on: ubuntu-latest
23
- timeout-minutes: 10
24
- steps:
25
- - uses: actions/stale@v10
26
- with:
27
- days-before-issue-stale: 30
28
- days-before-issue-close: 30
29
- stale-issue-label: 'stale'
30
- any-of-issue-labels: 'awaiting response'
31
- exempt-issue-labels: 'good first task,help wanted'
32
- stale-issue-message: >
33
- This issue is marked as awaiting a response from the reporter and
34
- has had no activity for 30 days. It will be closed in another 30
35
- days if there is no further update. If the issue is still
36
- relevant, please reply with any missing information.
37
- close-issue-message: >
38
- Closing this issue because it has been stale for 60 days without
39
- a response. Please reopen if this is still relevant.
40
- days-before-pr-stale: -1
41
- days-before-pr-close: -1
42
- operations-per-run: 100
package/.prettierignore DELETED
@@ -1,6 +0,0 @@
1
- node_modules
2
- public/remoteEntry.js
3
- public/540.js
4
- public/805.js
5
- public/main.js
6
- public/540.js.LICENSE.txt
package/.prettierrc DELETED
@@ -1,6 +0,0 @@
1
- {
2
- "semi": false,
3
- "singleQuote": true,
4
- "trailingComma": "none",
5
- "printWidth": 120
6
- }
package/eslint.config.js DELETED
@@ -1,59 +0,0 @@
1
- const prettier = require('eslint-config-prettier')
2
-
3
- module.exports = [
4
- { ignores: ['src/**', 'public/remoteEntry.js', 'public/540.js', 'public/805.js', 'public/main.js'] },
5
- {
6
- files: ['plugin/**/*.js', 'test/**/*.js'],
7
- languageOptions: {
8
- ecmaVersion: 2022,
9
- sourceType: 'commonjs',
10
- globals: {
11
- require: 'readonly',
12
- module: 'readonly',
13
- exports: 'readonly',
14
- __dirname: 'readonly',
15
- __filename: 'readonly',
16
- process: 'readonly',
17
- console: 'readonly',
18
- setTimeout: 'readonly',
19
- clearTimeout: 'readonly',
20
- setInterval: 'readonly',
21
- clearInterval: 'readonly',
22
- globalThis: 'readonly'
23
- }
24
- },
25
- rules: {
26
- 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
27
- 'no-undef': 'error'
28
- }
29
- },
30
- {
31
- files: ['public/dock.js'],
32
- languageOptions: {
33
- ecmaVersion: 2022,
34
- sourceType: 'script',
35
- globals: {
36
- window: 'readonly',
37
- document: 'readonly',
38
- navigator: 'readonly',
39
- fetch: 'readonly',
40
- console: 'readonly',
41
- setTimeout: 'readonly',
42
- clearTimeout: 'readonly',
43
- setInterval: 'readonly',
44
- requestAnimationFrame: 'readonly',
45
- Promise: 'readonly',
46
- Set: 'readonly',
47
- Infinity: 'readonly',
48
- MutationObserver: 'readonly',
49
- URLSearchParams: 'readonly',
50
- location: 'readonly'
51
- }
52
- },
53
- rules: {
54
- 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
55
- 'no-undef': 'error'
56
- }
57
- },
58
- prettier
59
- ]