signalk-chiplog 2.11.0 → 2.13.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/CHANGELOG.md CHANGED
@@ -6,6 +6,34 @@ All notable changes to Chiplog are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.13.0] - 2026-10-24
10
+
11
+ ### Added
12
+
13
+ - **Remove the pairing** from the Export page, after a confirmation: the plugin forgets its token and the backup stops.
14
+ What is already on the service stays there.
15
+
16
+ ### Changed
17
+
18
+ - **Online backup says why the service cannot be reached.** Instead of "fetch failed", pairing, backup and restore name
19
+ the host and the cause (name not resolved, connection refused, certificate refused, no answer in time), with a hint
20
+ for the common ones — a wrong clock behind a certificate refused. A pairing that cannot start is written to the server
21
+ log.
22
+ - **Pairing no longer asks for the service address.** It is `https://app.tibord.com` for a new plugin; another one, for
23
+ testing or development, is set in the plugin configuration.
24
+
25
+ ## [2.12.0] - 2026-10-24
26
+
27
+ ### Added
28
+
29
+ - **Helm entry beside the chart.** Chartplotters supporting the Plotter Extensions API can open Chiplog from a toolbar
30
+ button. The iframe panel keeps drafts while hidden and follows the plotter's night mode when available. The standalone
31
+ tablet app and its offline queue remain available.
32
+
33
+ ### Fixed
34
+
35
+ - Event and crew deletion confirmations work inside sandboxed plotter panels.
36
+
9
37
  ## [2.11.0] - 2026-10-24
10
38
 
11
39
  ### Added
@@ -483,7 +511,9 @@ First release.
483
511
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
484
512
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
485
513
 
486
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.11.0...HEAD
514
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.13.0...HEAD
515
+ [2.13.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.12.0...v2.13.0
516
+ [2.12.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.11.0...v2.12.0
487
517
  [2.11.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.10.0...v2.11.0
488
518
  [2.10.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.9.0...v2.10.0
489
519
  [2.9.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.8.0...v2.9.0
package/README.md CHANGED
@@ -437,6 +437,18 @@ Logging needs read/write access. The first time, the app shows **This tablet nee
437
437
  To revoke it, delete the device under **Security → Devices**: the tablet asks for access again. **Sign in instead** uses
438
438
  a regular Signal K user account.
439
439
 
440
+ ### From a chartplotter
441
+
442
+ In a chartplotter supporting the Signal K Plotter Extensions API (such as Freeboard-SK), the **Chiplog** toolbar button
443
+ opens the helm entry app in an iframe panel beside the chart. Closing and reopening it keeps notes and handwriting in
444
+ progress; reloading the plotter still discards unsent drafts. The usual offline queue and Signal K access rights apply.
445
+ The panel follows the plotter's night mode when available, and its night switch also changes the plotter's mode.
446
+ Standalone tablet use remains available at the same address.
447
+
448
+ The extension is advertised automatically while the plugin is enabled. A server without the resource-provider API can
449
+ still run the standalone apps. Journal and login navigation links are hidden in the extension: sign in through the
450
+ chartplotter, or use the panel's device access request.
451
+
440
452
  ## Configuration 🔧
441
453
 
442
454
  In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
@@ -525,14 +537,17 @@ Keep a copy of the logbook off the boat, on the Ti Bord online service. What it
525
537
  and how you can share your passages and voyages from it, is at [tibord.com](https://tibord.com), which the **Online
526
538
  backup** card links to as well.
527
539
 
528
- 1. On the **Export** page, under **Online backup**, enter the service address and choose **Get a pairing code**.
540
+ 1. On the **Export** page, under **Online backup**, choose **Get a pairing code**.
529
541
  2. On the service, signed in to your account, enter the code shown (e.g. `K7QF-3MXB`) and choose your boat — or scan the
530
542
  QR code beside it with your phone, which opens the service with the code filled in. The code is valid for 15 minutes.
531
- 3. The page soon says the boat is paired: Chiplog has saved the service address and its own token in the plugin
532
- configuration and turned the backup on.
543
+ 3. The page soon says the boat is paired: Chiplog has saved its own token in the plugin configuration and turned the
544
+ backup on.
545
+
546
+ You can also paste a token by hand: turn **Back the logbook up online** on in the plugin configuration and fill in the
547
+ **Device token**. The **Online service address** is `https://app.tibord.com`; change it only to test another instance.
533
548
 
534
- You can also paste a token by hand: turn **Back the logbook up online** on in the plugin configuration, fill in the
535
- **Online service address** and the **Device token**.
549
+ **Remove the pairing**, on the same card, asks for a confirmation, then forgets the token and stops the backup. What is
550
+ already on the service stays there; revoke the device from your account on the service to stop it for good.
536
551
 
537
552
  From then on, whenever the boat has a connection, Chiplog sends:
538
553
 
package/index.js CHANGED
@@ -8,6 +8,7 @@ const { INFLUX_DEFAULTS } = require('./lib/influx-history');
8
8
  const { createLandmarkFinder, LANDMARK_DEFAULTS } = require('./lib/landmark-finder');
9
9
  const { OBSERVATION_DEFAULTS } = require('./lib/observation-recorder');
10
10
  const { createPlaceNamer, GEOCODING_DEFAULTS } = require('./lib/place-names');
11
+ const { registerPlotterExtension } = require('./lib/plotter-extension');
11
12
  const { PROPULSION_DEFAULTS } = require('./lib/propulsion-detector');
12
13
  const { createReplayJob } = require('./lib/replay-job');
13
14
  const { createSummaryMailer, SUMMARY_MAIL_DEFAULTS } = require('./lib/summary-mailer');
@@ -277,7 +278,9 @@ module.exports = function (app) {
277
278
  cloudSyncUrl: {
278
279
  type: 'string',
279
280
  title: 'Online service address',
280
- description: 'The address of the Ti Bord service: https://app.tibord.com'
281
+ description:
282
+ 'The address of the Ti Bord service. Leave it as it is unless you test another instance',
283
+ default: CLOUD_SYNC_DEFAULTS.cloudSyncUrl
281
284
  },
282
285
  cloudSyncToken: {
283
286
  type: 'string',
@@ -590,6 +593,28 @@ module.exports = function (app) {
590
593
  });
591
594
  }
592
595
 
596
+ // Forgets the pairing from the Export page: the token leaves the plugin configuration and the
597
+ // backup turns off; the service address stays. What the service holds stays there.
598
+ function removePairing() {
599
+ const next = { ...savedConfig, cloudSyncEnabled: false };
600
+ delete next.cloudSyncToken;
601
+ return new Promise((resolve, reject) => {
602
+ app.savePluginOptions(next, (err) => {
603
+ if (err) {
604
+ reject(err);
605
+ return;
606
+ }
607
+ savedConfig = next;
608
+ if (settings) {
609
+ settings.cloudSyncEnabled = false;
610
+ settings.cloudSyncToken = null;
611
+ }
612
+ cloudPairing?.cancel();
613
+ resolve();
614
+ });
615
+ });
616
+ }
617
+
593
618
  // Declares the link costly or cheap from the Export page, saved like the rest of the
594
619
  // configuration. Back to cheap, a run starts at once: full passages have been waiting.
595
620
  function saveCostlyLink(costly) {
@@ -653,7 +678,7 @@ module.exports = function (app) {
653
678
  ? SUMMARY_MAIL_DEFAULTS.summaryMailTileUrl
654
679
  : config.summaryMailTileUrl,
655
680
  cloudSyncEnabled: config.cloudSyncEnabled ?? CLOUD_SYNC_DEFAULTS.cloudSyncEnabled,
656
- cloudSyncUrl: config.cloudSyncUrl || null,
681
+ cloudSyncUrl: config.cloudSyncUrl || CLOUD_SYNC_DEFAULTS.cloudSyncUrl,
657
682
  cloudSyncToken: config.cloudSyncToken || null,
658
683
  cloudSyncIntervalMinutes:
659
684
  config.cloudSyncIntervalMinutes ?? CLOUD_SYNC_DEFAULTS.cloudSyncIntervalMinutes,
@@ -706,6 +731,8 @@ module.exports = function (app) {
706
731
  throw err;
707
732
  }
708
733
 
734
+ registerPlotterExtension(app, () => database !== null);
735
+
709
736
  const readSelfPath = (path) => app.getSelfPath(path);
710
737
  detector = createPassageDetector({ db: database, readSelfPath, settings });
711
738
  const recorder = createTrackRecorder({ db: database, readSelfPath, settings });
@@ -762,6 +789,7 @@ module.exports = function (app) {
762
789
  });
763
790
  cloudPairing = createCloudPairing({
764
791
  db: database,
792
+ serviceUrl: () => settings.cloudSyncUrl,
765
793
  userAgent: `signalk-chiplog/${version}`,
766
794
  log,
767
795
  onPaired: ({ url, token }) => savePairing(url, token)
@@ -883,6 +911,7 @@ module.exports = function (app) {
883
911
  cloudSync,
884
912
  nudgeCloudSync: () => cloudSchedule?.nudge(),
885
913
  saveCostlyLink,
914
+ removePairing,
886
915
  cloudPairing,
887
916
  cloudRestore,
888
917
  replayJob,
package/lib/api.js CHANGED
@@ -778,16 +778,36 @@ function registerRoutes(router, { getContext, logError }) {
778
778
  })
779
779
  );
780
780
 
781
+ // Forgets the pairing: the device token is removed from the plugin configuration and the
782
+ // backup turns off. The service keeps what it holds, and the device until revoked there.
783
+ admin.delete(
784
+ '/api/cloud-sync/token',
785
+ handle(async ({ cloudSync, cloudPairing, cloudRestore, removePairing }) => {
786
+ try {
787
+ await removePairing();
788
+ } catch (err) {
789
+ throw conflict('cloud_sync_save_failed', err.message);
790
+ }
791
+ return {
792
+ ...cloudSync.status(),
793
+ pairing: cloudPairing.status(),
794
+ restore: cloudRestore.status()
795
+ };
796
+ })
797
+ );
798
+
781
799
  // Asks the service for a pairing code, then waits in the background for someone to claim
782
- // it; GET /cloud-sync follows the pairing.
800
+ // it; GET /cloud-sync follows the pairing. The service is the configured one unless `url`
801
+ // names another.
783
802
  admin.post(
784
803
  '/api/cloud-sync/pairing',
785
804
  handle(async ({ cloudPairing }, req) => {
786
- if (typeof req.body?.url !== 'string' || req.body.url.trim() === '') {
787
- throw badRequest('url is required');
805
+ const url = req.body?.url;
806
+ if (url !== undefined && (typeof url !== 'string' || url.trim() === '')) {
807
+ throw badRequest('url must be a non-empty string');
788
808
  }
789
809
  try {
790
- return await cloudPairing.start(req.body.url.trim());
810
+ return await cloudPairing.start(url?.trim());
791
811
  } catch (err) {
792
812
  if (err.code === 'invalid_url') {
793
813
  throw badRequest(err.message);
@@ -1,5 +1,6 @@
1
1
  const { getLogbookId } = require('./cloud-sync');
2
2
  const { qrCode } = require('./qr');
3
+ const { fetchExplained } = require('./network-error');
3
4
 
4
5
  const REQUEST_TIMEOUT_MS = 30 * 1000;
5
6
  // When the service is out of reach mid-pairing: keep asking, as long as the code lives.
@@ -32,6 +33,8 @@ function serviceUrl(text) {
32
33
  // given the service address and the token, to save them in the plugin configuration.
33
34
  function createCloudPairing({
34
35
  db,
36
+ // The address to pair with when none is given: the one in the plugin configuration.
37
+ serviceUrl: configuredUrl = () => null,
35
38
  userAgent,
36
39
  onPaired,
37
40
  log,
@@ -44,16 +47,20 @@ function createCloudPairing({
44
47
  let timer = null;
45
48
 
46
49
  async function call(url, path, body) {
47
- const response = await fetch(`${url}/v1${path}`, {
48
- method: 'POST',
49
- headers: {
50
- 'content-type': 'application/json',
51
- 'user-agent': userAgent,
52
- 'x-chiplog-version': userAgent.split('/')[1] ?? ''
50
+ const response = await fetchExplained(
51
+ fetch,
52
+ `${url}/v1${path}`,
53
+ {
54
+ method: 'POST',
55
+ headers: {
56
+ 'content-type': 'application/json',
57
+ 'user-agent': userAgent,
58
+ 'x-chiplog-version': userAgent.split('/')[1] ?? ''
59
+ },
60
+ body: JSON.stringify(body)
53
61
  },
54
- body: JSON.stringify(body),
55
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
56
- });
62
+ REQUEST_TIMEOUT_MS
63
+ );
57
64
  const data = await response.json().catch(() => ({}));
58
65
  if (!response.ok) {
59
66
  throw new PairingError(data.message ?? `The service answered ${response.status}`, {
@@ -87,6 +94,7 @@ function createCloudPairing({
87
94
  state = { state: 'expired', at: new Date(clock()).toISOString() };
88
95
  } else if (!(error instanceof PairingError)) {
89
96
  // Out of reach for a moment: the code is still good.
97
+ log('debug', `Online backup pairing: ${error.message}; retrying`);
90
98
  schedule(current, RETRY_MS);
91
99
  } else {
92
100
  attempt = null;
@@ -119,16 +127,22 @@ function createCloudPairing({
119
127
 
120
128
  return {
121
129
  // Asks the service for a code and starts waiting for it to be claimed. A pairing already
122
- // under way is dropped: its code was not used.
123
- async start(text) {
124
- const url = serviceUrl(text);
130
+ // under way is dropped: its code was not used. Without an address, the configured one.
131
+ async start(text = configuredUrl()) {
132
+ const url = serviceUrl(text ?? '');
125
133
  if (!url) {
126
134
  throw new PairingError('The service address must be an http or https URL', {
127
135
  code: 'invalid_url'
128
136
  });
129
137
  }
130
138
  this.cancel();
131
- const pairing = await call(url, '/pairings', { logbookId: getLogbookId(db) });
139
+ let pairing;
140
+ try {
141
+ pairing = await call(url, '/pairings', { logbookId: getLogbookId(db) });
142
+ } catch (error) {
143
+ log('error', `Online backup pairing could not start: ${error.message}`);
144
+ throw error;
145
+ }
132
146
  const current = {
133
147
  url,
134
148
  pairingId: pairing.pairingId,
@@ -1,6 +1,7 @@
1
1
  const { withTransaction } = require('./database');
2
2
  const { getLogbookId, isRestoring } = require('./cloud-sync');
3
3
  const { TRACK_READINGS, OBSERVATION_READINGS } = require('./passage-import');
4
+ const { fetchExplained } = require('./network-error');
4
5
 
5
6
  const REQUEST_TIMEOUT_MS = 2 * 60 * 1000;
6
7
 
@@ -250,15 +251,19 @@ function createCloudRestore({
250
251
  const isEmpty = () => db.prepare('SELECT count(*) AS n FROM log_entries').get().n === 0;
251
252
 
252
253
  async function call(path) {
253
- const response = await fetch(`${settings.cloudSyncUrl.replace(/\/+$/, '')}/v1${path}`, {
254
- method: 'GET',
255
- headers: {
256
- authorization: `Bearer ${settings.cloudSyncToken}`,
257
- 'user-agent': userAgent,
258
- 'x-chiplog-version': userAgent.split('/')[1] ?? ''
254
+ const response = await fetchExplained(
255
+ fetch,
256
+ `${settings.cloudSyncUrl.replace(/\/+$/, '')}/v1${path}`,
257
+ {
258
+ method: 'GET',
259
+ headers: {
260
+ authorization: `Bearer ${settings.cloudSyncToken}`,
261
+ 'user-agent': userAgent,
262
+ 'x-chiplog-version': userAgent.split('/')[1] ?? ''
263
+ }
259
264
  },
260
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
261
- });
265
+ REQUEST_TIMEOUT_MS
266
+ );
262
267
  if (!response.ok) {
263
268
  let detail = {};
264
269
  try {
package/lib/cloud-sync.js CHANGED
@@ -9,9 +9,13 @@ const { allSegments } = require('./propulsion');
9
9
  const { getTideForecast } = require('./tide-forecaster');
10
10
  const { allObservations, allTrackPoints } = require('./track');
11
11
  const { getWeatherForecast } = require('./weather-forecaster');
12
+ const { fetchExplained } = require('./network-error');
12
13
 
13
14
  const CLOUD_SYNC_DEFAULTS = {
14
15
  cloudSyncEnabled: false,
16
+ // Where every boat backs up; another address is for testing and development, set in the
17
+ // plugin configuration.
18
+ cloudSyncUrl: 'https://app.tibord.com',
15
19
  cloudSyncIntervalMinutes: 15,
16
20
  cloudSyncCostlyLink: false
17
21
  };
@@ -180,12 +184,16 @@ function createCloudSync({
180
184
  headers['content-encoding'] = 'gzip';
181
185
  payload = zlib.gzipSync(JSON.stringify(body));
182
186
  }
183
- const response = await fetch(`${base()}${path}`, {
184
- method,
185
- headers,
186
- body: payload,
187
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
188
- });
187
+ const response = await fetchExplained(
188
+ fetch,
189
+ `${base()}${path}`,
190
+ {
191
+ method,
192
+ headers,
193
+ body: payload
194
+ },
195
+ REQUEST_TIMEOUT_MS
196
+ );
189
197
  if (!response.ok) {
190
198
  let detail = {};
191
199
  try {
@@ -0,0 +1,72 @@
1
+ // Node's fetch says only "fetch failed" when no answer came back; the reason — a name that does
2
+ // not resolve, a refused connection, a certificate refused — is in `cause`, which the plugin
3
+ // would otherwise lose. These turn such a failure into one sentence for the log and the page.
4
+
5
+ // A hint for the causes a boat meets most, where the fix is not obvious from the code alone.
6
+ const HINTS = [
7
+ [/^(ENOTFOUND|EAI_AGAIN)$/, 'check the address and that this server can resolve names (DNS)'],
8
+ [
9
+ /^(CERT_|UNABLE_TO_VERIFY|SELF_SIGNED|DEPTH_ZERO|ERR_TLS_CERT)/,
10
+ "check this server's clock: a wrong date makes valid certificates look invalid"
11
+ ],
12
+ [/^(ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH)$/, 'check the address and the network']
13
+ ];
14
+
15
+ function describeCause(cause) {
16
+ if (!cause) {
17
+ return null;
18
+ }
19
+ // Several addresses tried (IPv4 and IPv6): each has its own reason.
20
+ if (Array.isArray(cause.errors) && cause.errors.length > 0) {
21
+ const parts = [
22
+ ...new Set(cause.errors.map((each) => describeCause(each)?.text).filter(Boolean))
23
+ ];
24
+ return { code: cause.code ?? cause.errors[0].code ?? null, text: parts.join('; ') };
25
+ }
26
+ const message = cause.message || String(cause);
27
+ const code = cause.code ?? null;
28
+ const text = code && !message.includes(code) ? `${code}: ${message}` : message;
29
+ return { code, text };
30
+ }
31
+
32
+ // The error a failed `fetch(url)` threw, explained. An answer the service gave is not this
33
+ // function's: only failures where no answer came back are rewritten; anything else is returned
34
+ // as it was.
35
+ function networkError(error, url, timeoutMs) {
36
+ let host;
37
+ try {
38
+ host = new URL(url).host;
39
+ } catch {
40
+ host = url;
41
+ }
42
+ if (error?.name === 'TimeoutError') {
43
+ const result = new Error(
44
+ `No answer from ${host} within ${Math.round(timeoutMs / 1000)} s: check the network`
45
+ );
46
+ result.code = 'timeout';
47
+ result.cause = error;
48
+ return result;
49
+ }
50
+ if (!(error instanceof TypeError) || error.message !== 'fetch failed') {
51
+ return error;
52
+ }
53
+ const cause = describeCause(error.cause);
54
+ const hint = cause?.code && HINTS.find(([pattern]) => pattern.test(cause.code))?.[1];
55
+ const result = new Error(
56
+ `Cannot reach ${host}${cause ? ` (${cause.text})` : ''}${hint ? `: ${hint}` : ''}`
57
+ );
58
+ result.code = cause?.code ?? 'fetch_failed';
59
+ result.cause = error;
60
+ return result;
61
+ }
62
+
63
+ // `fetch`, with what went wrong said when no answer came back.
64
+ async function fetchExplained(fetch, url, options, timeoutMs) {
65
+ try {
66
+ return await fetch(url, { ...options, signal: AbortSignal.timeout(timeoutMs) });
67
+ } catch (error) {
68
+ throw networkError(error, url, timeoutMs);
69
+ }
70
+ }
71
+
72
+ module.exports = { networkError, fetchExplained };
@@ -0,0 +1,59 @@
1
+ const { version } = require('../package.json');
2
+
3
+ const EXTENSION_ID = 'signalk-chiplog';
4
+
5
+ function buildManifest() {
6
+ return {
7
+ name: 'Chiplog — Helm entry',
8
+ description: 'Log manoeuvres, notes and handwriting without leaving the chart.',
9
+ version,
10
+ apiVersion: '1',
11
+ requires: ['panels.iframe', 'buttons'],
12
+ optional: ['nightMode'],
13
+ panels: [
14
+ {
15
+ id: 'helm-entry',
16
+ title: 'Chiplog',
17
+ type: 'iframe',
18
+ url: '/signalk-chiplog/entry/?plotterExtension=1',
19
+ lifecycle: 'keepAlive'
20
+ }
21
+ ],
22
+ buttons: [
23
+ {
24
+ id: 'open-helm-entry',
25
+ title: 'Chiplog',
26
+ slot: 'mapToolbar',
27
+ icon: 'edit_note',
28
+ action: { type: 'togglePanel', panel: 'helm-entry' }
29
+ }
30
+ ]
31
+ };
32
+ }
33
+
34
+ function registerPlotterExtension(app, isRunning) {
35
+ // Older servers can still run the standalone logbook and tablet app.
36
+ if (typeof app.registerResourceProvider !== 'function') {
37
+ return;
38
+ }
39
+ app.registerResourceProvider({
40
+ type: 'plotterExtensions',
41
+ methods: {
42
+ listResources: async () => (isRunning() ? { [EXTENSION_ID]: buildManifest() } : {}),
43
+ getResource: async (id) => {
44
+ if (!isRunning() || id !== EXTENSION_ID) {
45
+ throw new Error(`No such plotterExtensions resource: ${id}`);
46
+ }
47
+ return buildManifest();
48
+ },
49
+ setResource: async () => {
50
+ throw new Error('Chiplog plotter extension is read-only');
51
+ },
52
+ deleteResource: async () => {
53
+ throw new Error('Chiplog plotter extension is read-only');
54
+ }
55
+ }
56
+ });
57
+ }
58
+
59
+ module.exports = { registerPlotterExtension };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-chiplog",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -81,6 +81,7 @@
81
81
  "mediabunny": "1.58.0",
82
82
  "preact": "^10.29.8",
83
83
  "prettier": "^3.9.6",
84
+ "signalk-plotterext-bus": "^0.21.0",
84
85
  "three": "0.186.0"
85
86
  }
86
87
  }
package/public/app.css CHANGED
@@ -1153,12 +1153,6 @@ button.preset-on {
1153
1153
  margin-bottom: 1rem;
1154
1154
  }
1155
1155
 
1156
- .pairing-form label {
1157
- display: grid;
1158
- gap: 0.25rem;
1159
- flex: 1 1 16rem;
1160
- }
1161
-
1162
1156
  .pairing-code .code {
1163
1157
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1164
1158
  font-size: 2rem;
@@ -749,6 +749,32 @@ input {
749
749
  border-radius: var(--radius);
750
750
  }
751
751
 
752
+ .confirmation-dialog {
753
+ width: min(40rem, calc(100% - 2rem));
754
+ max-height: calc(100% - 2rem);
755
+ padding: 0;
756
+ border: 0;
757
+ border-radius: var(--radius);
758
+ background: var(--surface);
759
+ color: var(--text);
760
+ }
761
+
762
+ .confirmation-dialog::backdrop {
763
+ background: rgb(0 0 0 / 55%);
764
+ }
765
+
766
+ .confirmation-dialog .sheet {
767
+ width: 100%;
768
+ }
769
+
770
+ :root[data-plotter-extension='true'] [data-standalone-link] {
771
+ display: none;
772
+ }
773
+
774
+ :root[data-plotter-extension='true'] .entry-main {
775
+ padding: 0.75rem 0.75rem 9rem;
776
+ }
777
+
752
778
  .sail-grid {
753
779
  display: grid;
754
780
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
@@ -63,7 +63,7 @@ export function AccessGate({ onGranted }) {
63
63
  ${t('access.request')}
64
64
  </button>`
65
65
  }
66
- <a class="big-button" href="/admin/#/login">${t('access.signIn')}</a>
66
+ <a class="big-button" data-standalone-link href="/admin/#/login">${t('access.signIn')}</a>
67
67
  </div>
68
68
  </section>
69
69
  `;
@@ -1,6 +1,7 @@
1
1
  import { html, useState } from '../../../vendor/preact-htm.mjs';
2
2
  import { useLocale } from '../../../js/context.mjs';
3
3
  import { PencilIcon, TrashIcon } from './Icons.mjs';
4
+ import { confirmAction } from '../confirm.mjs';
4
5
 
5
6
  // .big-button is a row flex container, so a bare <br/> between its children
6
7
  // would not stack them -- wrap name and role in their own column instead.
@@ -83,7 +84,7 @@ export function CrewDialog({
83
84
  };
84
85
 
85
86
  const removeMember = async (member) => {
86
- if (!confirm(t('entry.deleteCrewMemberConfirm', { name: member.name }))) {
87
+ if (!(await confirmAction(t('entry.deleteCrewMemberConfirm', { name: member.name }), t))) {
87
88
  return;
88
89
  }
89
90
  const removed = await onDeleteMember(member.id);
@@ -95,7 +95,7 @@ export function StatusHeader({
95
95
  >
96
96
  ${t('entry.night')}
97
97
  </button>
98
- <a class="tool-button" href="../">${t('entry.logbook')}</a>
98
+ <a class="tool-button" data-standalone-link href="../">${t('entry.logbook')}</a>
99
99
  </div>
100
100
  </header>
101
101
  `;
@@ -0,0 +1,42 @@
1
+ // Native confirm() is blocked in plotter-extension sandboxes. An HTML dialog
2
+ // works there, traps focus, supports Escape, and keeps the same confirmation
3
+ // in the standalone app. Text is never interpreted as HTML.
4
+ export function confirmAction(message, t) {
5
+ return new Promise((resolve) => {
6
+ const dialog = document.createElement('dialog');
7
+ dialog.className = 'confirmation-dialog';
8
+ dialog.setAttribute('aria-label', message);
9
+ const form = document.createElement('form');
10
+ form.method = 'dialog';
11
+ form.className = 'sheet';
12
+ const text = document.createElement('p');
13
+ text.textContent = message;
14
+ const actions = document.createElement('div');
15
+ actions.className = 'sheet-row';
16
+ for (const [value, label, className] of [
17
+ ['cancel', t('common.cancel'), 'big-button'],
18
+ ['confirm', t('common.delete'), 'big-button danger']
19
+ ]) {
20
+ const button = document.createElement('button');
21
+ button.type = 'submit';
22
+ button.value = value;
23
+ button.textContent = label;
24
+ button.className = className;
25
+ button.autofocus = value === 'cancel';
26
+ actions.append(button);
27
+ }
28
+ form.append(text, actions);
29
+ dialog.append(form);
30
+ dialog.addEventListener(
31
+ 'close',
32
+ () => {
33
+ const confirmed = dialog.returnValue === 'confirm';
34
+ dialog.remove();
35
+ resolve(confirmed);
36
+ },
37
+ { once: true }
38
+ );
39
+ document.body.append(dialog);
40
+ dialog.showModal();
41
+ });
42
+ }
@@ -12,6 +12,8 @@ import { randomId } from '../../js/ids.mjs';
12
12
  import { pickLanguage } from '../../js/i18n.mjs';
13
13
  import { createServerClock } from './clock.mjs';
14
14
  import { createJournal } from './journal.mjs';
15
+ import { confirmAction } from './confirm.mjs';
16
+ import { connectPlotter, isPlotterExtension } from './plotter.mjs';
15
17
  import { AccessGate } from './components/AccessGate.mjs';
16
18
  import { EventDialog, Toast } from './components/Dialogs.mjs';
17
19
  import { CrewDialog } from './components/CrewDialog.mjs';
@@ -106,6 +108,7 @@ function App({ journal }) {
106
108
  const [managingCrew, setManagingCrew] = useState(false);
107
109
  const [tab, setTab] = useState('note');
108
110
  const [night, setNight] = useState(readSetting(NIGHT_KEY) === 'on');
111
+ const [plotter, setPlotter] = useState(null);
109
112
  const [version, setVersion] = useState(0);
110
113
  const [busy, setBusy] = useState(false);
111
114
 
@@ -163,6 +166,43 @@ function App({ journal }) {
163
166
  writeSetting(NIGHT_KEY, night ? 'on' : 'off');
164
167
  }, [night]);
165
168
 
169
+ useEffect(() => {
170
+ if (!isPlotterExtension) {
171
+ return undefined;
172
+ }
173
+ let active = true;
174
+ let connection;
175
+ connectPlotter((enabled) => active && setNight(enabled))
176
+ .then((client) => {
177
+ if (!active) {
178
+ client.close();
179
+ return;
180
+ }
181
+ connection = client;
182
+ setPlotter(client);
183
+ })
184
+ // The tablet remains usable if the host cannot complete the handshake.
185
+ .catch(() => {});
186
+ return () => {
187
+ active = false;
188
+ connection?.close();
189
+ };
190
+ }, []);
191
+
192
+ const toggleNight = async () => {
193
+ const enabled = !night;
194
+ if (plotter?.hasCapability('nightMode')) {
195
+ try {
196
+ await plotter.call('nightMode.set', { enabled, auto: false });
197
+ setNight(enabled);
198
+ return;
199
+ } catch {
200
+ // Keep the local night switch usable if the host stops answering.
201
+ }
202
+ }
203
+ setNight(enabled);
204
+ };
205
+
166
206
  useEffect(() => {
167
207
  if (!toast || toast.kind === 'error') {
168
208
  return undefined;
@@ -307,7 +347,7 @@ function App({ journal }) {
307
347
  };
308
348
 
309
349
  const deleteEvent = async (event) => {
310
- if (!confirm(t('entry.deleteConfirm'))) {
350
+ if (!(await confirmAction(t('entry.deleteConfirm'), t))) {
311
351
  return;
312
352
  }
313
353
  try {
@@ -376,7 +416,7 @@ function App({ journal }) {
376
416
  onReconnect=${reconnect}
377
417
  queued=${queue.pending.length}
378
418
  night=${night}
379
- onToggleNight=${() => setNight((value) => !value)}
419
+ onToggleNight=${toggleNight}
380
420
  />
381
421
  <main class="entry-main">
382
422
  ${
@@ -498,6 +538,7 @@ if ('serviceWorker' in navigator && window.isSecureContext) {
498
538
  }
499
539
 
500
540
  const locale = createLocale(pickLanguage(navigator.languages, location.search));
541
+ document.documentElement.dataset.plotterExtension = String(isPlotterExtension);
501
542
  document.documentElement.lang = locale.language;
502
543
  document.title = locale.t('entry.title');
503
544
 
@@ -0,0 +1,30 @@
1
+ import { connectExtension, windowPort } from '../../vendor/plotterext.mjs';
2
+
3
+ // An explicit opt-in leaves standalone and ordinary iframe use unchanged.
4
+ export const isPlotterExtension =
5
+ window.parent !== window && new URLSearchParams(location.search).get('plotterExtension') === '1';
6
+
7
+ export async function connectPlotter(onNightChange) {
8
+ const client = await connectExtension({
9
+ port: windowPort(window.parent, { origin: location.origin }),
10
+ timeoutMs: 5000
11
+ });
12
+ try {
13
+ if (client.hasCapability('nightMode')) {
14
+ // Subscribe first so a change during the initial read cannot be lost.
15
+ let changed = false;
16
+ await client.subscribe(['nightMode.changed'], (_name, state) => {
17
+ changed = true;
18
+ onNightChange(state.enabled);
19
+ });
20
+ const state = await client.call('nightMode.get');
21
+ if (!changed) {
22
+ onNightChange(state.enabled);
23
+ }
24
+ }
25
+ return client;
26
+ } catch (error) {
27
+ client.close();
28
+ throw error;
29
+ }
30
+ }
@@ -13,6 +13,8 @@ const SHELL = [
13
13
  'icons/icon-512.png',
14
14
  'icons/apple-touch-icon.png',
15
15
  'js/main.mjs',
16
+ 'js/confirm.mjs',
17
+ 'js/plotter.mjs',
16
18
  'js/access.mjs',
17
19
  'js/clock.mjs',
18
20
  'js/journal.mjs',
@@ -43,7 +45,8 @@ const SHELL = [
43
45
  '../js/view-memory.mjs',
44
46
  '../js/components/common.mjs',
45
47
  '../js/components/Timeline.mjs',
46
- '../vendor/preact-htm.mjs'
48
+ '../vendor/preact-htm.mjs',
49
+ '../vendor/plotterext.mjs'
47
50
  ];
48
51
 
49
52
  const shellUrls = () => new Set(SHELL.map((file) => new URL(file, self.registration.scope).href));
@@ -105,27 +105,16 @@ function PairingState({ pairing, onCancel }) {
105
105
  return null;
106
106
  }
107
107
 
108
- function PairingForm({ initialUrl, onStart, busy }) {
108
+ function PairingForm({ onStart, busy }) {
109
109
  const { t } = useLocale();
110
- const [url, setUrl] = useState(initialUrl ?? '');
111
110
  return html`
112
111
  <form
113
112
  class="pairing-form"
114
113
  onSubmit=${(event) => {
115
114
  event.preventDefault();
116
- onStart(url);
115
+ onStart();
117
116
  }}
118
117
  >
119
- <label>
120
- ${t('cloud.serviceUrl')}
121
- <input
122
- type="url"
123
- required
124
- placeholder="https://"
125
- value=${url}
126
- onInput=${(event) => setUrl(event.currentTarget.value)}
127
- />
128
- </label>
129
118
  <button type="submit" disabled=${busy}>${t('cloud.pair')}</button>
130
119
  </form>
131
120
  `;
@@ -279,6 +268,17 @@ export function CloudBackup() {
279
268
  onStart=${(logbookId) => act('POST', '/cloud-sync/restore', { logbookId })}
280
269
  />`
281
270
  }
271
+ ${
272
+ status?.configured &&
273
+ html`<button
274
+ type="button"
275
+ class="danger"
276
+ disabled=${busy}
277
+ onClick=${() => confirm(t('cloud.unpairConfirm')) && act('DELETE', '/cloud-sync/token')}
278
+ >
279
+ ${t('cloud.unpair')}
280
+ </button>`
281
+ }
282
282
  ${
283
283
  status?.pairing &&
284
284
  html`<${PairingState}
@@ -293,11 +293,7 @@ export function CloudBackup() {
293
293
  <details open=${!ready}>
294
294
  <summary>${ready ? t('cloud.pairAgain') : t('cloud.pairTitle')}</summary>
295
295
  <p>${t('cloud.pairIntro')}</p>
296
- <${PairingForm}
297
- initialUrl=${status.url}
298
- busy=${busy}
299
- onStart=${(url) => act('POST', '/cloud-sync/pairing', { url })}
300
- />
296
+ <${PairingForm} busy=${busy} onStart=${() => act('POST', '/cloud-sync/pairing', {})} />
301
297
  </details>
302
298
  `
303
299
  }
@@ -452,11 +452,13 @@ export const MESSAGES = {
452
452
  'cloud.restored': 'Logbook restored: {count} passages brought back.',
453
453
  'cloud.restoreRefused': '{count} could not be written; see the server log.',
454
454
  'cloud.restoreFailed': 'The restore stopped: {message}',
455
- 'cloud.pairAgain': 'Pair with another account or service',
455
+ 'cloud.pairAgain': 'Pair with another account',
456
+ 'cloud.unpair': 'Remove the pairing',
457
+ 'cloud.unpairConfirm':
458
+ 'Remove the pairing? The backup stops and this plugin forgets its token. What is already on Ti Bord stays there; revoke the device from your account to stop it for good.',
456
459
  'cloud.pairIntro':
457
- 'Enter the service address and ask for a code, then enter the code on the service, signed in to your account. The backup turns on as soon as it is claimed.',
460
+ 'Ask for a code, then enter it on Ti Bord, signed in to your account. The backup turns on as soon as it is claimed.',
458
461
  'cloud.learnMore': 'More about the backups, and about sharing your passages and voyages:',
459
- 'cloud.serviceUrl': 'Service address',
460
462
  'cloud.pair': 'Get a pairing code',
461
463
  'cloud.enterCode': 'Enter this code on the service:',
462
464
  'cloud.openClaim': 'Open the service to enter it',
@@ -1037,12 +1039,14 @@ export const MESSAGES = {
1037
1039
  'cloud.restored': 'Journal restauré : {count} navigations récupérées.',
1038
1040
  'cloud.restoreRefused': '{count} n’ont pas pu être écrites ; voir le journal du serveur.',
1039
1041
  'cloud.restoreFailed': 'La restauration s’est arrêtée : {message}',
1040
- 'cloud.pairAgain': 'Appairer avec un autre compte ou service',
1042
+ 'cloud.pairAgain': 'Appairer avec un autre compte',
1043
+ 'cloud.unpair': 'Supprimer l’appairage',
1044
+ 'cloud.unpairConfirm':
1045
+ 'Supprimer l’appairage ? La sauvegarde s’arrête et ce plugin oublie son jeton. Ce qui est déjà sur Ti Bord y reste ; révoquez l’appareil depuis votre compte pour l’arrêter définitivement.',
1041
1046
  'cloud.pairIntro':
1042
- 'Indiquez l’adresse du service et demandez un code, puis saisissez ce code sur le service, connecté à votre compte. La sauvegarde s’active dès qu’il est utilisé.',
1047
+ 'Demandez un code, puis saisissez-le sur Ti Bord, connecté à votre compte. La sauvegarde s’active dès qu’il est utilisé.',
1043
1048
  'cloud.learnMore':
1044
1049
  'Plus d’informations sur les sauvegardes et sur le partage de vos navigations et de vos voyages :',
1045
- 'cloud.serviceUrl': 'Adresse du service',
1046
1050
  'cloud.pair': 'Obtenir un code d’appairage',
1047
1051
  'cloud.enterCode': 'Saisissez ce code sur le service :',
1048
1052
  'cloud.openClaim': 'Ouvrir le service pour le saisir',
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joel Kozikowski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ var f="plotterExt/1",o={PARSE_ERROR:-32700,INVALID_REQUEST:-32600,METHOD_NOT_FOUND:-32601,INVALID_PARAMS:-32602,INTERNAL_ERROR:-32603,HOST_ERROR:-32e3,TIMEOUT:-32001,CONNECTION_CLOSED:-32002},c=class l extends Error{code;data;constructor(e,s={}){super(e),this.name="RpcError",this.code=s.code??o.HOST_ERROR;let n={...s.data??{}};s.reason!==void 0&&(n.reason=s.reason),this.data=Object.keys(n).length>0?n:void 0}get reason(){return typeof this.data?.reason=="string"?this.data.reason:void 0}toErrorObject(){return{code:this.code,message:this.message,...this.data?{data:this.data}:{}}}static fromErrorObject(e){return new l(e.message,{code:e.code,data:e.data})}static from(e){return e instanceof l?e:e instanceof Error?new l(e.message,{code:o.INTERNAL_ERROR}):new l(String(e),{code:o.INTERNAL_ERROR})}},u="bus.ready",y="bus.handshake";function E(t,e){return t===e?!0:m(t.split("."),0,e.split("."),0)}function m(t,e,s,n){for(;e<t.length;){let i=t[e];if(i==="**"){if(e===t.length-1)return!0;for(let r=n;r<=s.length;r++)if(m(t,e+1,s,r))return!0;return!1}if(n>=s.length||i!=="*"&&i!==s[n])return!1;e++,n++}return n===s.length}function g(t,e){for(let s of t)if(E(s,e))return!0;return!1}function N(t){return{bus:f,msg:t}}function M(t){if(typeof t!="object"||t===null)return null;let e=t;return e.bus!==f?null:_(e.msg)?e.msg:null}function _(t){if(typeof t!="object"||t===null)return!1;let e=t;if(e.jsonrpc!=="2.0")return!1;if(typeof e.method=="string")return e.id===void 0||typeof e.id=="string"||typeof e.id=="number";if(!(typeof e.id=="string"||typeof e.id=="number"||e.id===null))return!1;let n="result"in e,i=e.error,r=typeof i=="object"&&i!==null&&typeof i.code=="number"&&typeof i.message=="string";return n?!("error"in e):r}function S(t){return"method"in t&&"id"in t&&t.id!==void 0}function A(t){return"method"in t&&(!("id"in t)||t.id===void 0)}function D(t){return!("method"in t)}function h(t,e={}){let s=e.listenWindow??globalThis,n=e.origin??s.location?.origin??"*";return{post(i,r){r?.length?t.postMessage(i,{targetOrigin:n,transfer:r}):t.postMessage(i,n)},listen(i){let r=a=>{a.source===t&&(n!=="*"&&a.origin!==n||i(a.data))};return s.addEventListener("message",r),()=>s.removeEventListener("message",r)}}}var L=1e4,p=class{callTimeoutMs;port;unlisten;onError;pending=new Map;methods=new Map;eventHandlers=new Set;idPrefix=Math.random().toString(36).slice(2,8);seq=0;closed=!1;constructor(t){this.port=t.port,this.callTimeoutMs=t.callTimeoutMs??L,this.onError=t.onError??(e=>console.warn("[plotterext-bus]",e)),this.unlisten=this.port.listen(e=>this.onData(e))}registerMethod(t,e){this.methods.set(t,e)}unregisterMethod(t){this.methods.delete(t)}onEvent(t,e){let s={patterns:t,fn:e};return this.eventHandlers.add(s),()=>this.eventHandlers.delete(s)}notify(t,e,s={}){this.send({jsonrpc:"2.0",method:t,...e!==void 0?{params:e}:{}},s.transfer)}call(t,e,s={}){if(this.closed)return Promise.reject(new c("Bus endpoint is closed",{code:o.CONNECTION_CLOSED,reason:"CLOSED"}));let n=`${this.idPrefix}-${++this.seq}`,i=s.timeoutMs??this.callTimeoutMs;return new Promise((r,a)=>{let d=i>0?setTimeout(()=>{this.pending.delete(n),a(new c(`Call timed out after ${i}ms: ${t}`,{code:o.TIMEOUT,reason:"TIMEOUT"}))},i):null;this.pending.set(n,{resolve:r,reject:a,timer:d}),this.send({jsonrpc:"2.0",id:n,method:t,...e!==void 0?{params:e}:{}},s.transfer)})}close(){if(!this.closed){this.closed=!0,this.unlisten();for(let[,t]of this.pending)t.timer&&clearTimeout(t.timer),t.reject(new c("Bus endpoint closed",{code:o.CONNECTION_CLOSED,reason:"CLOSED"}));this.pending.clear(),this.eventHandlers.clear()}}send(t,e){this.closed||this.port.post(N(t),e)}onData(t){let e=M(t);e&&(D(e)?this.onResponse(e):S(e)?this.onRequest(e):A(e)&&this.onNotification(e.method,e.params))}onResponse(t){if(t.id===null)return;let e=this.pending.get(t.id);e&&(this.pending.delete(t.id),e.timer&&clearTimeout(e.timer),"error"in t?e.reject(c.fromErrorObject(t.error)):e.resolve(t.result))}async onRequest(t){let e=this.methods.get(t.method);if(!e){this.send({jsonrpc:"2.0",id:t.id,error:{code:o.METHOD_NOT_FOUND,message:`Method not found: ${t.method}`}});return}try{let s=await e(t.params,{endpoint:this});this.send({jsonrpc:"2.0",id:t.id,result:s===void 0?null:s})}catch(s){this.send({jsonrpc:"2.0",id:t.id,error:c.from(s).toErrorObject()})}}onNotification(t,e){for(let s of[...this.eventHandlers])if(g(s.patterns,t))try{s.fn(t,e)}catch(n){this.onError(n)}}};var v=class{handshake;endpoint;constructor(t,e){this.endpoint=t,this.handshake=e}get context(){return this.handshake.context}get apiVersion(){return this.handshake.apiVersion}get capabilities(){return this.handshake.capabilities}hasCapability(t){return this.handshake.capabilities.includes(t)}call(t,e,s){return this.endpoint.call(t,e,s)}notify(t,e){this.endpoint.notify(t,e)}async subscribe(t,e){let s=this.endpoint.onEvent(t,e),n;try{n=(await this.call("events.subscribe",{patterns:t})).subscriptionId}catch(i){throw s(),i}return async()=>{s(),await this.call("events.unsubscribe",{subscriptionId:n}).catch(()=>{})}}async publish(t,e,s){await this.call("events.publish",{topic:t,...e!==void 0?{params:e}:{},...s?{scope:s}:{}})}state={get:async(t,e)=>(await this.call("state.get",{...e?{scope:e}:{},...t?{keys:t}:{}})).values??{},set:async(t,e)=>{await this.call("state.set",{...e?{scope:e}:{},values:t})}};signalk={subscribe:async(t,e)=>{let s=t.map(r=>`sk.${r}`),n=await this.subscribe(s,(r,a)=>e(a)),i;try{i=(await this.call("signalk.subscribe",{paths:t})).subscriptionId}catch(r){throw await n(),r}return async()=>{await n(),await this.call("signalk.unsubscribe",{subscriptionId:i}).catch(()=>{})}},put:(t,e)=>this.call("signalk.put",{path:t,value:e})};route={list:async()=>(await this.call("route.list")).routes??[],create:async t=>await this.call("route.create",t),get:async t=>await this.call("route.get",{routeId:t}),replace:async(t,e)=>await this.call("route.replace",{routeId:t,points:e}),save:async(t,e)=>await this.call("route.save",{routeId:t,...e??{}}),show:async t=>await this.call("route.show",{ref:t}),hide:async t=>{await this.call("route.hide",{routeId:t})},delete:async t=>{await this.call("route.delete",{routeId:t})}};chart={list:async()=>(await this.call("chart.list")).charts??[],setVisibility:async(t,e)=>{await this.call("chart.setVisibility",{ids:t,visible:e})},setOpacity:async(t,e)=>{await this.call("chart.setOpacity",{ids:t,opacity:e})},setOrder:async t=>{await this.call("chart.setOrder",{order:t})},setTime:async(t,e)=>{await this.call("chart.setTime",{ids:t,time:e})}};nightMode={get:async()=>await this.call("nightMode.get"),set:async t=>{await this.call("nightMode.set",t)}};panels={list:async()=>(await this.call("ui.listPanels")).panels};overlays={list:async()=>(await this.call("overlay.list")).overlays,setVisibility:async(t,e)=>{await this.call("overlay.setVisibility",{ids:t,visible:e})},setOpacity:async(t,e)=>{await this.call("overlay.setOpacity",{ids:t,opacity:e})},frame:async t=>await this.call("overlay.frame",t,{transfer:[t.image]}),refresh:async t=>{await this.call("overlay.refresh",t)},setData:async t=>{await this.call("overlay.setData",t)},setLayer:async t=>{await this.call("overlay.setLayer",t)},setState:async(t,e)=>{await this.call("overlay.setState",{overlayId:t,values:e})}};windows={open:async t=>await this.call("ui.openWindow",t),update:async t=>await this.call("ui.updateWindow",t),focus:async t=>{await this.call("ui.focusWindow",t===void 0?{}:{windowId:t})},close:async t=>{await this.call("ui.closeWindow",t===void 0?{}:{windowId:t})},list:async()=>(await this.call("ui.listWindows")).windows};resourceGroup={apply:async t=>await this.call("resourceGroup.apply",{id:t})};map={getView:async()=>await this.call("map.getView"),center:async(t,e)=>{await this.call("map.center",{position:t,...typeof e=="number"?{zoom:e}:{}})},fitBounds:async t=>{await this.call("map.fitBounds",{bounds:t})}};close(){this.endpoint.close()}};function R(t={}){let e=t.port??h(globalThis.parent,{origin:"*"}),s=new p({port:e,callTimeoutMs:t.callTimeoutMs,onError:t.onError}),n=t.id?{id:t.id}:void 0;return new Promise((i,r)=>{let a=!1,d=s.onEvent([y],(I,T)=>{a||(a=!0,w(),i(new v(s,T)))}),b=setInterval(()=>s.notify(u,n),t.readyIntervalMs??250),O=setTimeout(()=>{a||(a=!0,w(),s.close(),r(new c("Timed out waiting for host handshake",{code:o.TIMEOUT,reason:"HANDSHAKE_TIMEOUT"})))},t.timeoutMs??1e4),w=()=>{d(),clearInterval(b),clearTimeout(O)};s.notify(u,n)})}export{R as connectExtension,h as windowPort};