signalk-chiplog 2.7.0 → 2.9.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/README.md +152 -45
  3. package/docs/screenshots/05-stats.png +0 -0
  4. package/docs/screenshots/06-3d-animation.png +0 -0
  5. package/index.js +241 -4
  6. package/lib/api.js +123 -2
  7. package/lib/cloud-pairing.js +172 -0
  8. package/lib/cloud-restore.js +416 -0
  9. package/lib/cloud-sync.js +455 -0
  10. package/lib/database.js +50 -1
  11. package/lib/detection.js +4 -0
  12. package/lib/events.js +84 -20
  13. package/lib/export.js +1 -1
  14. package/lib/mail.js +162 -0
  15. package/lib/passage-map.js +342 -0
  16. package/lib/passage-summary.js +169 -0
  17. package/lib/places.js +6 -0
  18. package/lib/png.js +295 -0
  19. package/lib/qr.js +266 -0
  20. package/lib/smtp.js +242 -0
  21. package/lib/summary-mailer.js +286 -0
  22. package/package.json +2 -1
  23. package/public/app.css +94 -0
  24. package/public/entry/entry.css +94 -1
  25. package/public/entry/icons/apple-touch-icon.png +0 -0
  26. package/public/entry/icons/icon-192.png +0 -0
  27. package/public/entry/icons/icon-512.png +0 -0
  28. package/public/entry/index.html +2 -2
  29. package/public/entry/js/components/Dialogs.mjs +82 -16
  30. package/public/entry/js/components/Icons.mjs +33 -0
  31. package/public/entry/js/components/NotePanel.mjs +21 -3
  32. package/public/entry/js/components/RecentList.mjs +2 -3
  33. package/public/entry/js/journal.mjs +24 -7
  34. package/public/entry/js/main.mjs +27 -18
  35. package/public/entry/manifest.webmanifest +1 -1
  36. package/public/entry/sw.js +2 -0
  37. package/public/icon-small.svg +37 -0
  38. package/public/icon.svg +82 -6
  39. package/public/index.html +1 -1
  40. package/public/js/components/CloudBackup.mjs +298 -0
  41. package/public/js/components/ExportView.mjs +3 -0
  42. package/public/js/components/LogView.mjs +63 -6
  43. package/public/js/components/PassageView.mjs +4 -3
  44. package/public/js/components/Timeline.mjs +40 -14
  45. package/public/js/context.mjs +22 -3
  46. package/public/js/format.mjs +34 -0
  47. package/public/js/i18n.mjs +107 -2
  48. package/public/js/log-lines.mjs +5 -0
  49. package/public/js/main.mjs +1 -1
  50. package/public/js/view-memory.mjs +39 -0
package/index.js CHANGED
@@ -10,6 +10,11 @@ const { OBSERVATION_DEFAULTS } = require('./lib/observation-recorder');
10
10
  const { createPlaceNamer, GEOCODING_DEFAULTS } = require('./lib/place-names');
11
11
  const { PROPULSION_DEFAULTS } = require('./lib/propulsion-detector');
12
12
  const { createReplayJob } = require('./lib/replay-job');
13
+ const { createSummaryMailer, SUMMARY_MAIL_DEFAULTS } = require('./lib/summary-mailer');
14
+ const { createCloudSync, CLOUD_SYNC_DEFAULTS } = require('./lib/cloud-sync');
15
+ const { createCloudPairing } = require('./lib/cloud-pairing');
16
+ const { createCloudRestore } = require('./lib/cloud-restore');
17
+ const { SECURITIES } = require('./lib/smtp');
13
18
  const { createTideForecaster, TIDE_DEFAULTS } = require('./lib/tide-forecaster');
14
19
  const { createWeatherForecaster, WEATHER_DEFAULTS } = require('./lib/weather-forecaster');
15
20
  const { createTrackRecorder, SAMPLE_INTERVAL_MS, TRACK_DEFAULTS } = require('./lib/track-recorder');
@@ -30,6 +35,12 @@ const FIRST_FORECAST_DELAY_MS = 5 * 1000;
30
35
  // Landmarks are not needed for the passage under way, only for reading it back,
31
36
  // so the first lookup waits for the busier start-up work to be done.
32
37
  const FIRST_LANDMARK_DELAY_MS = 30 * 1000;
38
+ // Likewise: a summary is never urgent, and the passage it is about has been
39
+ // over for at least the reopening delay by the time it is due.
40
+ const FIRST_SUMMARY_MAIL_DELAY_MS = 60 * 1000;
41
+ // The first run after a start reads every passage to work out its hashes: not
42
+ // while detection and the other chains are starting up.
43
+ const FIRST_CLOUD_SYNC_DELAY_MS = 2 * 60 * 1000;
33
44
 
34
45
  const MOTION_LABELS = { underway: 'Under way', stopped: 'Stopped', unknown: 'Waiting for data' };
35
46
  const MODE_LABELS = { autostate: 'navigation.state', fallback: 'speed fallback' };
@@ -64,6 +75,13 @@ module.exports = function (app) {
64
75
  // The open passage detection last reported, to notice a new one.
65
76
  let lastActiveEntryId = null;
66
77
  let usbExport = null;
78
+ let summaryMailer = null;
79
+ let cloudSync = null;
80
+ let cloudSchedule = null;
81
+ let cloudPairing = null;
82
+ let cloudRestore = null;
83
+ // The configuration as saved, so pairing can add the token to it without losing the rest.
84
+ let savedConfig = {};
67
85
  let replayJob = null;
68
86
  let namingTimer = null;
69
87
  let timers = [];
@@ -195,19 +213,106 @@ module.exports = function (app) {
195
213
  title: 'Copy to the USB drive at each arrival',
196
214
  default: USB_EXPORT_DEFAULTS.usbExportOnArrival
197
215
  },
216
+ summaryMailEnabled: {
217
+ type: 'boolean',
218
+ title: 'Email a summary of each passage',
219
+ description:
220
+ 'Once a passage is over for good — past the delay within which a new departure would carry it on — sends its map and figures to the addresses below',
221
+ default: SUMMARY_MAIL_DEFAULTS.summaryMailEnabled
222
+ },
223
+ summaryMailTo: {
224
+ type: 'string',
225
+ title: 'Summary recipients',
226
+ description: 'One or more email addresses, separated by commas'
227
+ },
228
+ summaryMailFrom: {
229
+ type: 'string',
230
+ title: 'Summary sender',
231
+ description:
232
+ 'The address the summaries are sent from, e.g. logbook@example.org. Most relays insist it be one they are allowed to send for'
233
+ },
234
+ smtpHost: {
235
+ type: 'string',
236
+ title: 'SMTP server',
237
+ description: 'The mail relay the summaries are handed to. Leave empty to turn them off'
238
+ },
239
+ smtpPort: {
240
+ type: 'number',
241
+ title: 'SMTP port',
242
+ description: '587 for submission with STARTTLS, 465 for implicit TLS, 25 for a local relay',
243
+ default: SUMMARY_MAIL_DEFAULTS.smtpPort,
244
+ minimum: 1
245
+ },
246
+ smtpSecurity: {
247
+ type: 'string',
248
+ title: 'SMTP security',
249
+ enum: SECURITIES,
250
+ enumNames: ['STARTTLS (port 587)', 'TLS (port 465)', 'None (local relay only)'],
251
+ default: SUMMARY_MAIL_DEFAULTS.smtpSecurity
252
+ },
253
+ smtpUsername: {
254
+ type: 'string',
255
+ title: 'SMTP user name',
256
+ description: 'Leave empty if the relay needs no authentication'
257
+ },
258
+ smtpPassword: {
259
+ type: 'string',
260
+ title: 'SMTP password',
261
+ format: 'password'
262
+ },
263
+ summaryMailTileUrl: {
264
+ type: 'string',
265
+ title: 'Map tiles for the summary',
266
+ description:
267
+ 'The tiles the summary map is drawn over. The public OpenStreetMap instance by default (© OpenStreetMap contributors, ODbL), or a self-hosted one; empty draws the track on a plain background',
268
+ default: SUMMARY_MAIL_DEFAULTS.summaryMailTileUrl
269
+ },
270
+ cloudSyncEnabled: {
271
+ type: 'boolean',
272
+ title: 'Back the logbook up online',
273
+ description:
274
+ 'Sends each passage, with its track, events and readings, to the online service below as soon as there is a connection: new and changed passages, newest first, and the removal of those deleted here. Nothing is sent while this is off',
275
+ default: CLOUD_SYNC_DEFAULTS.cloudSyncEnabled
276
+ },
277
+ cloudSyncUrl: {
278
+ type: 'string',
279
+ title: 'Online service address',
280
+ description: 'The address of the Ti Bord service: https://app.tibord.com'
281
+ },
282
+ cloudSyncToken: {
283
+ type: 'string',
284
+ title: 'Device token',
285
+ description: 'The token the service gave this boat',
286
+ format: 'password'
287
+ },
288
+ cloudSyncIntervalMinutes: {
289
+ type: 'number',
290
+ title: 'Online backup interval (minutes)',
291
+ description:
292
+ 'How often to check for passages to send; each departure and arrival is sent straight away',
293
+ default: CLOUD_SYNC_DEFAULTS.cloudSyncIntervalMinutes,
294
+ minimum: 1
295
+ },
296
+ cloudSyncCostlyLink: {
297
+ type: 'boolean',
298
+ title: 'Costly link (satellite, roaming)',
299
+ description:
300
+ 'Sends only a summary of each passage and a thinned-out track, a few kilobytes, at departures and arrivals and every six hours. Full tracks, instrument readings, forecasts and places wait until this is turned off, or until Back up now is chosen on the Export page',
301
+ default: CLOUD_SYNC_DEFAULTS.cloudSyncCostlyLink
302
+ },
198
303
  logbookLanguage: {
199
304
  type: 'string',
200
- title: 'Logbook language (PDF)',
305
+ title: 'Logbook language (PDF and summary emails)',
201
306
  description:
202
- 'Language of the PDF logbook written to the USB drive; downloads from the webapp use the webapp language',
307
+ 'Language of the PDF logbook written to the USB drive and of the passage summary emails; downloads from the webapp use the webapp language',
203
308
  enum: PDF_LANGUAGES,
204
309
  default: 'en'
205
310
  },
206
311
  logbookTimeZone: {
207
312
  type: 'string',
208
- title: 'Ship’s time zone (PDF)',
313
+ title: 'Ship’s time zone (PDF and summary emails)',
209
314
  description:
210
- 'IANA time zone the PDF logbook on the USB drive is kept in, e.g. Europe/Paris. Empty uses the server’s time zone; downloads from the webapp use the browser’s'
315
+ 'IANA time zone the PDF logbook on the USB drive and the passage summary emails are kept in, e.g. Europe/Paris. Empty uses the server’s time zone; downloads from the webapp use the browser’s'
211
316
  },
212
317
  windSpeedThresholds: {
213
318
  type: 'array',
@@ -367,6 +472,9 @@ module.exports = function (app) {
367
472
  // retry delay left from an earlier failure.
368
473
  if (outcome.activeEntryId !== null && outcome.activeEntryId !== lastActiveEntryId) {
369
474
  schedules.forEach((schedule) => schedule.nudge());
475
+ } else if (outcome.activeEntryId !== lastActiveEntryId) {
476
+ // An arrival: back the finished passage up while the boat is in reach.
477
+ cloudSchedule?.nudge();
370
478
  }
371
479
  lastActiveEntryId = outcome.activeEntryId;
372
480
  const usbError = usbExport.status().lastError;
@@ -440,7 +548,57 @@ module.exports = function (app) {
440
548
  version
441
549
  });
442
550
 
551
+ // Saves what pairing brought into the plugin configuration, as if typed in the admin, and
552
+ // starts using it at once: the backup's settings are read on every run, so no restart.
553
+ function savePairing(url, token) {
554
+ const next = {
555
+ ...savedConfig,
556
+ cloudSyncEnabled: true,
557
+ cloudSyncUrl: url,
558
+ cloudSyncToken: token
559
+ };
560
+ return new Promise((resolve, reject) => {
561
+ app.savePluginOptions(next, (err) => {
562
+ if (err) {
563
+ reject(err);
564
+ return;
565
+ }
566
+ savedConfig = next;
567
+ if (settings) {
568
+ settings.cloudSyncEnabled = true;
569
+ settings.cloudSyncUrl = url;
570
+ settings.cloudSyncToken = token;
571
+ }
572
+ cloudSchedule?.nudge();
573
+ resolve();
574
+ });
575
+ });
576
+ }
577
+
578
+ // Declares the link costly or cheap from the Export page, saved like the rest of the
579
+ // configuration. Back to cheap, a run starts at once: full passages have been waiting.
580
+ function saveCostlyLink(costly) {
581
+ const next = { ...savedConfig, cloudSyncCostlyLink: costly };
582
+ return new Promise((resolve, reject) => {
583
+ app.savePluginOptions(next, (err) => {
584
+ if (err) {
585
+ reject(err);
586
+ return;
587
+ }
588
+ savedConfig = next;
589
+ if (settings) {
590
+ settings.cloudSyncCostlyLink = costly;
591
+ }
592
+ if (!costly) {
593
+ cloudSchedule?.nudge();
594
+ }
595
+ resolve();
596
+ });
597
+ });
598
+ }
599
+
443
600
  plugin.start = function (config = {}) {
601
+ savedConfig = config;
444
602
  try {
445
603
  const { db, migrated } = openDatabase(app.getDataDirPath());
446
604
  database = db;
@@ -465,6 +623,26 @@ module.exports = function (app) {
465
623
  usbExportIntervalMinutes:
466
624
  config.usbExportIntervalMinutes ?? USB_EXPORT_DEFAULTS.usbExportIntervalMinutes,
467
625
  usbExportOnArrival: config.usbExportOnArrival ?? USB_EXPORT_DEFAULTS.usbExportOnArrival,
626
+ summaryMailEnabled: config.summaryMailEnabled ?? SUMMARY_MAIL_DEFAULTS.summaryMailEnabled,
627
+ summaryMailTo: config.summaryMailTo || null,
628
+ summaryMailFrom: config.summaryMailFrom || null,
629
+ smtpHost: config.smtpHost || null,
630
+ smtpPort: config.smtpPort ?? SUMMARY_MAIL_DEFAULTS.smtpPort,
631
+ smtpSecurity: SECURITIES.includes(config.smtpSecurity)
632
+ ? config.smtpSecurity
633
+ : SUMMARY_MAIL_DEFAULTS.smtpSecurity,
634
+ smtpUsername: config.smtpUsername || null,
635
+ smtpPassword: config.smtpPassword || null,
636
+ summaryMailTileUrl:
637
+ config.summaryMailTileUrl === undefined
638
+ ? SUMMARY_MAIL_DEFAULTS.summaryMailTileUrl
639
+ : config.summaryMailTileUrl,
640
+ cloudSyncEnabled: config.cloudSyncEnabled ?? CLOUD_SYNC_DEFAULTS.cloudSyncEnabled,
641
+ cloudSyncUrl: config.cloudSyncUrl || null,
642
+ cloudSyncToken: config.cloudSyncToken || null,
643
+ cloudSyncIntervalMinutes:
644
+ config.cloudSyncIntervalMinutes ?? CLOUD_SYNC_DEFAULTS.cloudSyncIntervalMinutes,
645
+ cloudSyncCostlyLink: config.cloudSyncCostlyLink ?? CLOUD_SYNC_DEFAULTS.cloudSyncCostlyLink,
468
646
  logbookLanguage: PDF_LANGUAGES.includes(config.logbookLanguage)
469
647
  ? config.logbookLanguage
470
648
  : 'en',
@@ -542,8 +720,46 @@ module.exports = function (app) {
542
720
  // sailed through, each on its own chain rather than inside detection.
543
721
  const networkOptions = { db: database, settings, userAgent: `signalk-chiplog/${version}` };
544
722
  const log = (level, message) => (level === 'error' ? app.error(message) : app.debug(message));
723
+ summaryMailer = createSummaryMailer({
724
+ db: database,
725
+ settings,
726
+ userAgent: `signalk-chiplog/${version}`,
727
+ vesselName: () => readVesselName(app),
728
+ log
729
+ });
730
+ cloudSync = createCloudSync({
731
+ db: database,
732
+ settings,
733
+ userAgent: `signalk-chiplog/${version}`,
734
+ log
735
+ });
736
+ // Nudged at departures and arrivals, apart from the forecasts which only
737
+ // care about departures.
738
+ cloudSchedule = createBackgroundSchedule({
739
+ label: 'Online backup',
740
+ resolver: cloudSync,
741
+ log,
742
+ firstDelayMs: FIRST_CLOUD_SYNC_DELAY_MS
743
+ });
744
+ cloudPairing = createCloudPairing({
745
+ db: database,
746
+ userAgent: `signalk-chiplog/${version}`,
747
+ log,
748
+ onPaired: ({ url, token }) => savePairing(url, token)
749
+ });
750
+ cloudRestore = createCloudRestore({
751
+ db: database,
752
+ settings,
753
+ userAgent: `signalk-chiplog/${version}`,
754
+ log,
755
+ // The backup held off while passages were coming back: it has places to send, at least.
756
+ onDone: () => cloudSchedule?.nudge()
757
+ });
758
+ // A restore the last run left unfinished carries on by itself.
759
+ cloudRestore.resume();
545
760
  lastActiveEntryId = null;
546
761
  schedules = [
762
+ cloudSchedule,
547
763
  createBackgroundSchedule({
548
764
  label: 'Tide forecast',
549
765
  resolver: createTideForecaster(networkOptions),
@@ -561,6 +777,14 @@ module.exports = function (app) {
561
777
  resolver: createLandmarkFinder(networkOptions),
562
778
  log,
563
779
  firstDelayMs: FIRST_LANDMARK_DELAY_MS
780
+ }),
781
+ // Nothing nudges this one: a passage becomes due a summary by growing
782
+ // old enough, not by anything detection reports (SPEC §4.16).
783
+ createBackgroundSchedule({
784
+ label: 'Passage summary email',
785
+ resolver: summaryMailer,
786
+ log,
787
+ firstDelayMs: FIRST_SUMMARY_MAIL_DELAY_MS
564
788
  })
565
789
  ];
566
790
  lastStatus = null;
@@ -600,6 +824,13 @@ module.exports = function (app) {
600
824
  schedules = [];
601
825
  usbExport?.stop();
602
826
  usbExport = null;
827
+ summaryMailer = null;
828
+ cloudSync = null;
829
+ cloudSchedule = null;
830
+ cloudPairing?.stop();
831
+ cloudPairing = null;
832
+ cloudRestore?.stop();
833
+ cloudRestore = null;
603
834
  replayJob?.cancel();
604
835
  replayJob = null;
605
836
  detector = null;
@@ -629,6 +860,12 @@ module.exports = function (app) {
629
860
  observeEvent: (entryId, time) => detector.observeEvent(entryId, time),
630
861
  noteDeparture: (entryId) => detector.noteDeparture(entryId),
631
862
  usbExport,
863
+ summaryMailer,
864
+ cloudSync,
865
+ nudgeCloudSync: () => cloudSchedule?.nudge(),
866
+ saveCostlyLink,
867
+ cloudPairing,
868
+ cloudRestore,
632
869
  replayJob,
633
870
  pdfOptions,
634
871
  detection: () => ({
package/lib/api.js CHANGED
@@ -499,7 +499,7 @@ function registerRoutes(router, { getContext, logError }) {
499
499
 
500
500
  readwrite.patch(
501
501
  '/api/events/:id',
502
- handle(({ db }, req) => {
502
+ handle(({ db, now }, req) => {
503
503
  const id = v.parseId(req.params.id);
504
504
  const body = v.requireBody(req.body, ['time', 'comment', 'subtype', 'payload']);
505
505
  const patch = withoutUndefined({
@@ -508,7 +508,7 @@ function registerRoutes(router, { getContext, logError }) {
508
508
  subtype: optional(body, 'subtype', nullableString(200)),
509
509
  payload: optional(body, 'payload', parsePayload)
510
510
  });
511
- return events.updateEvent(db, id, patch);
511
+ return events.updateEvent(db, id, patch, { now: now() });
512
512
  })
513
513
  );
514
514
 
@@ -719,6 +719,127 @@ function registerRoutes(router, { getContext, logError }) {
719
719
  })
720
720
  );
721
721
 
722
+ readonly.get(
723
+ '/api/summary-mail',
724
+ handle(({ summaryMailer }) => summaryMailer.status())
725
+ );
726
+
727
+ // Sends one passage's summary now, whether or not it already went out: how
728
+ // the SMTP settings are tried without waiting for the next arrival.
729
+ admin.post(
730
+ '/api/entries/:id/summary-mail',
731
+ handle(async ({ db, summaryMailer }, req) => {
732
+ const id = entryId(req);
733
+ entries.requireEntryRow(db, id);
734
+ try {
735
+ return await summaryMailer.send(id);
736
+ } catch (err) {
737
+ throw conflict('summary_mail_failed', err.message);
738
+ }
739
+ })
740
+ );
741
+
742
+ readonly.get(
743
+ '/api/cloud-sync',
744
+ handle(({ cloudSync, cloudPairing, cloudRestore }) => ({
745
+ ...cloudSync.status(),
746
+ pairing: cloudPairing.status(),
747
+ restore: cloudRestore.status()
748
+ }))
749
+ );
750
+
751
+ // The earlier logbooks of this boat the service can give back, asked of the service now.
752
+ readonly.get(
753
+ '/api/cloud-sync/restore',
754
+ handle(async ({ cloudRestore }) => {
755
+ try {
756
+ return { ...cloudRestore.status(), logbooks: await cloudRestore.candidates() };
757
+ } catch (err) {
758
+ throw conflict('cloud_restore_failed', err.message);
759
+ }
760
+ })
761
+ );
762
+
763
+ // Starts bringing one of them back into this logbook, which must be empty; the restore
764
+ // goes on in the background and GET /cloud-sync follows it.
765
+ admin.post(
766
+ '/api/cloud-sync/restore',
767
+ handle(({ cloudRestore }, req) => {
768
+ const logbookId = req.body?.logbookId;
769
+ if (typeof logbookId !== 'string' || !/^[0-9a-f-]{36}$/i.test(logbookId)) {
770
+ throw badRequest('logbookId is required');
771
+ }
772
+ try {
773
+ cloudRestore.start(logbookId.toLowerCase());
774
+ } catch (err) {
775
+ throw conflict(err.code ?? 'cloud_restore_failed', err.message);
776
+ }
777
+ return cloudRestore.status();
778
+ })
779
+ );
780
+
781
+ // Asks the service for a pairing code, then waits in the background for someone to claim
782
+ // it; GET /cloud-sync follows the pairing.
783
+ admin.post(
784
+ '/api/cloud-sync/pairing',
785
+ handle(async ({ cloudPairing }, req) => {
786
+ if (typeof req.body?.url !== 'string' || req.body.url.trim() === '') {
787
+ throw badRequest('url is required');
788
+ }
789
+ try {
790
+ return await cloudPairing.start(req.body.url.trim());
791
+ } catch (err) {
792
+ if (err.code === 'invalid_url') {
793
+ throw badRequest(err.message);
794
+ }
795
+ throw conflict('cloud_pairing_failed', err.message);
796
+ }
797
+ })
798
+ );
799
+
800
+ admin.delete(
801
+ '/api/cloud-sync/pairing',
802
+ handle(({ cloudPairing }) => {
803
+ cloudPairing.cancel();
804
+ return cloudPairing.status();
805
+ })
806
+ );
807
+
808
+ // Starts a backup run now rather than at the next interval; the run itself
809
+ // goes on in the background, through its schedule, never alongside another.
810
+ admin.post(
811
+ '/api/cloud-sync',
812
+ handle(({ cloudSync, nudgeCloudSync }) => {
813
+ const status = cloudSync.status();
814
+ if (!status.enabled || !status.configured) {
815
+ throw conflict(
816
+ 'cloud_sync_not_configured',
817
+ `Turn the online backup on and fill in its settings first${status.problem ? `: ${status.problem}` : ''}`
818
+ );
819
+ }
820
+ // Asked for by hand, the run sends everything in full, costly link or not.
821
+ cloudSync.requestFull();
822
+ nudgeCloudSync();
823
+ return status;
824
+ })
825
+ );
826
+
827
+ // Declares the link costly, or cheap again (SPEC §4.17).
828
+ admin.post(
829
+ '/api/cloud-sync/costly-link',
830
+ handle(async ({ cloudSync, saveCostlyLink }, req) => {
831
+ if (typeof req.body?.costly !== 'boolean') {
832
+ throw badRequest('costly must be true or false');
833
+ }
834
+ try {
835
+ await saveCostlyLink(req.body.costly);
836
+ } catch (err) {
837
+ throw conflict('cloud_sync_save_failed', err.message);
838
+ }
839
+ return cloudSync.status();
840
+ })
841
+ );
842
+
722
843
  readonly.get(
723
844
  '/api/replay',
724
845
  handle(({ replayJob }) => replayJob.status())
@@ -0,0 +1,172 @@
1
+ const { getLogbookId } = require('./cloud-sync');
2
+ const { qrCode } = require('./qr');
3
+
4
+ const REQUEST_TIMEOUT_MS = 30 * 1000;
5
+ // When the service is out of reach mid-pairing: keep asking, as long as the code lives.
6
+ const RETRY_MS = 10 * 1000;
7
+
8
+ class PairingError extends Error {
9
+ constructor(message, { status = null, code = null } = {}) {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ }
14
+ }
15
+
16
+ function serviceUrl(text) {
17
+ let url;
18
+ try {
19
+ url = new URL(text);
20
+ } catch {
21
+ return null;
22
+ }
23
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
24
+ return null;
25
+ }
26
+ return url.toString().replace(/\/+$/, '');
27
+ }
28
+
29
+ // Pairs the plugin with the online service by a short code instead of a pasted token
30
+ // (SPEC §4.17): the service hands out a code, someone claims it for their boat, and the plugin,
31
+ // polling meanwhile with a secret only it holds, collects its device token. `onPaired` is
32
+ // given the service address and the token, to save them in the plugin configuration.
33
+ function createCloudPairing({
34
+ db,
35
+ userAgent,
36
+ onPaired,
37
+ log,
38
+ fetch = globalThis.fetch,
39
+ clock = Date.now
40
+ }) {
41
+ let state = { state: 'idle' };
42
+ // The current attempt's secret and address, never reported.
43
+ let attempt = null;
44
+ let timer = null;
45
+
46
+ 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] ?? ''
53
+ },
54
+ body: JSON.stringify(body),
55
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
56
+ });
57
+ const data = await response.json().catch(() => ({}));
58
+ if (!response.ok) {
59
+ throw new PairingError(data.message ?? `The service answered ${response.status}`, {
60
+ status: response.status,
61
+ code: data.error ?? null
62
+ });
63
+ }
64
+ return data;
65
+ }
66
+
67
+ function schedule(current, delayMs) {
68
+ clearTimeout(timer);
69
+ timer = setTimeout(() => poll(current), delayMs);
70
+ }
71
+
72
+ async function poll(current) {
73
+ if (attempt !== current) {
74
+ return;
75
+ }
76
+ let result;
77
+ try {
78
+ result = await call(current.url, `/pairings/${current.pairingId}/poll`, {
79
+ pollSecret: current.pollSecret
80
+ });
81
+ } catch (error) {
82
+ if (attempt !== current) {
83
+ return;
84
+ }
85
+ if (error.status === 410 || Date.parse(state.expiresAt) <= clock()) {
86
+ attempt = null;
87
+ state = { state: 'expired', at: new Date(clock()).toISOString() };
88
+ } else if (!(error instanceof PairingError)) {
89
+ // Out of reach for a moment: the code is still good.
90
+ schedule(current, RETRY_MS);
91
+ } else {
92
+ attempt = null;
93
+ state = { state: 'failed', error: { message: error.message, code: error.code } };
94
+ }
95
+ return;
96
+ }
97
+ if (attempt !== current) {
98
+ return;
99
+ }
100
+ if (result.status !== 'paired') {
101
+ schedule(current, current.pollIntervalMs);
102
+ return;
103
+ }
104
+ attempt = null;
105
+ try {
106
+ await onPaired({ url: current.url, token: result.token });
107
+ } catch (error) {
108
+ log('error', `Online backup paired, but its settings could not be saved: ${error.message}`);
109
+ state = { state: 'failed', error: { message: error.message, code: 'save_failed' } };
110
+ return;
111
+ }
112
+ state = {
113
+ state: 'paired',
114
+ vesselName: result.vesselName,
115
+ at: new Date(clock()).toISOString()
116
+ };
117
+ log('info', `Online backup paired with ${result.vesselName || 'a vessel'} at ${current.url}`);
118
+ }
119
+
120
+ return {
121
+ // 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);
125
+ if (!url) {
126
+ throw new PairingError('The service address must be an http or https URL', {
127
+ code: 'invalid_url'
128
+ });
129
+ }
130
+ this.cancel();
131
+ const pairing = await call(url, '/pairings', { logbookId: getLogbookId(db) });
132
+ const current = {
133
+ url,
134
+ pairingId: pairing.pairingId,
135
+ pollSecret: pairing.pollSecret,
136
+ pollIntervalMs: Math.max(1, pairing.pollIntervalSeconds) * 1000
137
+ };
138
+ attempt = current;
139
+ state = {
140
+ state: 'waiting',
141
+ url,
142
+ code: pairing.code,
143
+ claimUrl: pairing.claimUrl ?? null,
144
+ // The same address as a QR code, for a phone to open: the plugin's page is often on
145
+ // the chart table, the account on the phone in someone's hand.
146
+ claimQr: pairing.claimUrl ? qrCode(pairing.claimUrl) : null,
147
+ expiresAt: pairing.expiresAt
148
+ };
149
+ schedule(current, current.pollIntervalMs);
150
+ return this.status();
151
+ },
152
+
153
+ cancel() {
154
+ clearTimeout(timer);
155
+ attempt = null;
156
+ if (state.state === 'waiting') {
157
+ state = { state: 'idle' };
158
+ }
159
+ },
160
+
161
+ status() {
162
+ return { ...state };
163
+ },
164
+
165
+ stop() {
166
+ clearTimeout(timer);
167
+ attempt = null;
168
+ }
169
+ };
170
+ }
171
+
172
+ module.exports = { createCloudPairing, PairingError };