@readium/navigator 2.10.3 → 2.11.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.
Files changed (94) hide show
  1. package/dist/audio/AudioNavigator.js +1 -1
  2. package/dist/audio/AudioPoolManager.js +1 -1
  3. package/dist/audio/engine/WebAudioEngine.js +1 -1
  4. package/dist/divina/DivinaNavigator.js +1 -0
  5. package/dist/divina/DivinaPageManager.js +1 -0
  6. package/dist/divina/DivinaPagedPresenter.js +1 -0
  7. package/dist/divina/DivinaPeripherals.js +1 -0
  8. package/dist/divina/DivinaScrolledPresenter.js +1 -0
  9. package/dist/divina/DivinaSpreader.js +1 -0
  10. package/dist/divina/DivinaVariantSelector.js +1 -0
  11. package/dist/divina/preferences/DivinaDefaults.js +1 -0
  12. package/dist/divina/preferences/DivinaPreferences.js +1 -0
  13. package/dist/divina/preferences/DivinaPreferencesEditor.js +1 -0
  14. package/dist/divina/preferences/DivinaSettings.js +1 -0
  15. package/dist/divina/protection/DivinaNavigatorProtector.js +1 -0
  16. package/dist/epub/EpubNavigator.js +1 -1
  17. package/dist/epub/frame/FrameBlobBuilder.js +3 -1
  18. package/dist/epub/frame/FrameComms.js +1 -1
  19. package/dist/epub/frame/FrameManager.js +1 -1
  20. package/dist/epub/frame/FramePoolManager.js +1 -1
  21. package/dist/epub/fxl/FXLFrameManager.js +1 -1
  22. package/dist/epub/fxl/FXLFramePoolManager.js +1 -1
  23. package/dist/epub/fxl/FXLPeripherals.js +2 -2
  24. package/dist/helpers/serialize.js +1 -0
  25. package/dist/index.js +1 -1
  26. package/dist/peripherals/KeyboardConditionBridge.js +1 -1
  27. package/dist/peripherals/KeyboardPeripherals.js +1 -1
  28. package/dist/webpub/WebPubFrameManager.js +1 -1
  29. package/dist/webpub/WebPubFramePoolManager.js +1 -1
  30. package/dist/webpub/WebPubNavigator.js +1 -1
  31. package/package.json +5 -5
  32. package/src/audio/AudioNavigator.ts +107 -5
  33. package/src/audio/AudioPoolManager.ts +14 -8
  34. package/src/audio/engine/AudioEngine.ts +34 -1
  35. package/src/audio/engine/WebAudioEngine.ts +48 -3
  36. package/src/divina/DivinaNavigator.ts +704 -0
  37. package/src/divina/DivinaPageManager.ts +322 -0
  38. package/src/divina/DivinaPagedPresenter.ts +508 -0
  39. package/src/divina/DivinaPeripherals.ts +630 -0
  40. package/src/divina/DivinaScrolledPresenter.ts +516 -0
  41. package/src/divina/DivinaSpreader.ts +202 -0
  42. package/src/divina/DivinaVariantSelector.ts +77 -0
  43. package/src/divina/index.ts +8 -0
  44. package/src/divina/preferences/DivinaDefaults.ts +37 -0
  45. package/src/divina/preferences/DivinaPreferences.ts +77 -0
  46. package/src/divina/preferences/DivinaPreferencesEditor.ts +96 -0
  47. package/src/divina/preferences/DivinaSettings.ts +38 -0
  48. package/src/divina/preferences/index.ts +4 -0
  49. package/src/divina/protection/DivinaNavigatorProtector.ts +44 -0
  50. package/src/epub/EpubNavigator.ts +73 -7
  51. package/src/epub/frame/FrameBlobBuilder.ts +144 -39
  52. package/src/epub/frame/FrameComms.ts +42 -7
  53. package/src/epub/frame/FrameManager.ts +27 -16
  54. package/src/epub/frame/FramePoolManager.ts +106 -48
  55. package/src/epub/fxl/FXLFrameManager.ts +99 -34
  56. package/src/epub/fxl/FXLFramePoolManager.ts +98 -53
  57. package/src/epub/fxl/FXLPeripherals.ts +22 -1
  58. package/src/helpers/serialize.ts +9 -0
  59. package/src/index.ts +1 -0
  60. package/src/peripherals/KeyboardConditionBridge.ts +15 -5
  61. package/src/peripherals/KeyboardPeripherals.ts +13 -7
  62. package/src/webpub/WebPubFrameManager.ts +27 -16
  63. package/src/webpub/WebPubFramePoolManager.ts +5 -2
  64. package/src/webpub/WebPubNavigator.ts +44 -0
  65. package/types/src/audio/AudioNavigator.d.ts +29 -0
  66. package/types/src/audio/engine/AudioEngine.d.ts +27 -1
  67. package/types/src/audio/engine/WebAudioEngine.d.ts +14 -2
  68. package/types/src/divina/DivinaNavigator.d.ts +119 -0
  69. package/types/src/divina/DivinaPageManager.d.ts +66 -0
  70. package/types/src/divina/DivinaPagedPresenter.d.ts +122 -0
  71. package/types/src/divina/DivinaPeripherals.d.ts +121 -0
  72. package/types/src/divina/DivinaScrolledPresenter.d.ts +125 -0
  73. package/types/src/divina/DivinaSpreader.d.ts +65 -0
  74. package/types/src/divina/DivinaVariantSelector.d.ts +30 -0
  75. package/types/src/divina/index.d.ts +8 -0
  76. package/types/src/divina/preferences/DivinaDefaults.d.ts +18 -0
  77. package/types/src/divina/preferences/DivinaPreferences.d.ts +33 -0
  78. package/types/src/divina/preferences/DivinaPreferencesEditor.d.ts +20 -0
  79. package/types/src/divina/preferences/DivinaSettings.d.ts +26 -0
  80. package/types/src/divina/preferences/index.d.ts +4 -0
  81. package/types/src/divina/protection/DivinaNavigatorProtector.d.ts +14 -0
  82. package/types/src/epub/EpubNavigator.d.ts +7 -1
  83. package/types/src/epub/frame/FrameBlobBuilder.d.ts +19 -3
  84. package/types/src/epub/frame/FrameComms.d.ts +8 -1
  85. package/types/src/epub/frame/FrameManager.d.ts +3 -0
  86. package/types/src/epub/frame/FramePoolManager.d.ts +5 -1
  87. package/types/src/epub/fxl/FXLFrameManager.d.ts +7 -0
  88. package/types/src/epub/fxl/FXLFramePoolManager.d.ts +8 -2
  89. package/types/src/epub/fxl/FXLPeripherals.d.ts +3 -0
  90. package/types/src/helpers/serialize.d.ts +1 -0
  91. package/types/src/index.d.ts +1 -0
  92. package/types/src/peripherals/KeyboardPeripherals.d.ts +1 -0
  93. package/types/src/webpub/WebPubFrameManager.d.ts +3 -0
  94. package/types/src/webpub/WebPubNavigator.d.ts +2 -0
@@ -6,6 +6,7 @@ import { CommsEventKey, ContextMenuEvent, DecorationActivatedEvent, DecorationPo
6
6
  import { Decoration, OnDecorationActivatedEvent, OnDecorationPointerEnterEvent, OnDecorationPointerLeaveEvent, DecorationObserver, DecorableNavigator, DecoratorConfig, decorationsEqual, resolveDecorationForWire, supportsDecorationStyle as canRenderDecorationStyle, DecorationStyleType } from "../decorations/index.ts";
7
7
  import * as path from "path-browserify";
8
8
  import { FXLFrameManager } from "./fxl/FXLFrameManager.ts";
9
+ import { isTypedOMSupported } from "./fxl/FXLPeripherals.ts";
9
10
  import { FrameManager } from "./frame/FrameManager.ts";
10
11
  import { IEpubPreferences, EpubPreferences } from "./preferences/EpubPreferences.ts";
11
12
  import { IEpubDefaults, EpubDefaults } from "./preferences/EpubDefaults.ts";
@@ -21,6 +22,9 @@ import { IContentProtectionConfig, IKeyboardPeripheralsConfig, KeyboardPeriphera
21
22
  import { NavigatorProtector, NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT } from "../protection/NavigatorProtector.ts";
22
23
  import { KeyboardPeripherals, NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT } from "../peripherals/KeyboardPeripherals.ts";
23
24
  import { getScriptMode } from "../helpers/scriptMode.ts";
25
+ import { UpgradeInsecureRequests } from "./frame/FrameBlobBuilder.ts";
26
+
27
+ export type { UpgradeInsecureRequests };
24
28
 
25
29
  export type ManagerEventKey = "zoom";
26
30
 
@@ -31,6 +35,7 @@ export interface EpubNavigatorConfiguration {
31
35
  contentProtection?: IContentProtectionConfig;
32
36
  keyboardPeripherals?: IKeyboardPeripheralsConfig;
33
37
  decoratorConfig?: DecoratorConfig;
38
+ upgradeInsecureRequests?: UpgradeInsecureRequests;
34
39
  }
35
40
 
36
41
  export interface EpubNavigatorListeners {
@@ -77,6 +82,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
77
82
  private readonly container: HTMLElement;
78
83
  private readonly listeners: EpubNavigatorListeners;
79
84
  private framePool!: FramePoolManager | FXLFramePoolManager;
85
+ private _destroyed = false;
80
86
  private positions!: Locator[];
81
87
  private currentLocation!: Locator;
82
88
  private _currentTimelineItem: TimelineItem | undefined;
@@ -99,6 +105,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
99
105
  private readonly _injectablesConfig: IInjectablesConfig;
100
106
  private readonly _contentProtection: IContentProtectionConfig;
101
107
  private readonly _keyboardPeripherals: IKeyboardPeripheralsConfig;
108
+ private readonly _upgradeInsecureRequests: UpgradeInsecureRequests;
102
109
  private readonly _navigatorProtector: NavigatorProtector | null = null;
103
110
  private readonly _keyboardPeripheralsManager: KeyboardPeripherals | null = null;
104
111
  private readonly _suspiciousActivityListener: ((event: Event) => void) | null = null;
@@ -177,6 +184,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
177
184
  this._contentProtection = configuration.contentProtection || {};
178
185
  this._decoratorConfig = configuration.decoratorConfig || {};
179
186
  this._decorationResizeSelectors = new Set(this._decoratorConfig.resizeWatchSelectors ?? []);
187
+ this._upgradeInsecureRequests = configuration.upgradeInsecureRequests ?? 'always';
180
188
 
181
189
  // Merge keyboard peripherals
182
190
  this._keyboardPeripherals = this.mergeKeyboardPeripherals(
@@ -264,21 +272,26 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
264
272
  });
265
273
  }
266
274
 
275
+ if (this._destroyed) return;
276
+
267
277
  if(this._layout === Layout.fixed) {
268
278
  this.framePool = new FXLFramePoolManager(
269
279
  this.container,
270
280
  this.positions,
271
281
  this.pub,
282
+ this._layout,
272
283
  this._injector,
273
284
  this._contentProtection,
274
285
  this._keyboardPeripherals,
275
- [...this._decorationResizeSelectors]
286
+ [...this._decorationResizeSelectors],
287
+ this._upgradeInsecureRequests
276
288
  );
277
289
  this.framePool.listener = (key: CommsEventKey | ManagerEventKey, data: unknown) => {
278
290
  this.eventListener(key, data);
279
291
  }
280
292
  } else {
281
293
  await this.updateCSS(false);
294
+ if (this._destroyed) return;
282
295
  const cssProperties = this.compileCSSProperties(this._css);
283
296
  this.framePool = new FramePoolManager(
284
297
  this.container,
@@ -291,15 +304,22 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
291
304
  .flatMap(item => item.references)
292
305
  .map(ref => { const h = ref.indexOf('#'); return h >= 0 ? ref.slice(h + 1) : ''; })
293
306
  .filter(Boolean),
294
- [...this._decorationResizeSelectors]
307
+ [...this._decorationResizeSelectors],
308
+ this._upgradeInsecureRequests
295
309
  );
296
310
  }
297
311
 
298
312
  if(this.currentLocation === undefined)
299
313
  this.currentLocation = this.positions[0];
314
+ else
315
+ this.currentLocation = this.completeLocator(this.currentLocation);
300
316
 
301
317
  await this.resizeHandler();
302
- await this.apply();
318
+ if (this._destroyed) return;
319
+ return new Promise(res => {
320
+ if (this._destroyed) return res(false);
321
+ this.go(this.currentLocation, false, res);
322
+ });
303
323
  }
304
324
 
305
325
  public get settings(): Readonly<EpubSettings> {
@@ -404,7 +424,12 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
404
424
  const parentEl = this.container.parentElement || document.documentElement;
405
425
 
406
426
  if (this._layout === Layout.fixed) {
407
- this.container.style.width = `${ getContentWidth(parentEl) - this._settings.constraint }px`;
427
+ const width = getContentWidth(parentEl) - this._settings.constraint;
428
+ if (isTypedOMSupported()) {
429
+ this.container.attributeStyleMap.set("width", CSS.px(width));
430
+ } else {
431
+ this.container.style.width = `${ width }px`;
432
+ }
408
433
  if (!this.framePool) return;
409
434
  (this.framePool as FXLFramePoolManager).resizeHandler();
410
435
  } else {
@@ -633,7 +658,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
633
658
  if(this._layout === Layout.fixed) {
634
659
  return modules.filter((m) => FXLModules.includes(m));
635
660
  } else modules = modules.filter((m) => ReflowableModules.includes(m));
636
-
661
+
637
662
  // CJK/Mongolian vertical: uses the X-axis snapper, never column or scroll snappers
638
663
  const mode = getScriptMode(this.pub.metadata);
639
664
  if (mode === 'cjk-vertical' || mode === 'mongolian-vertical') {
@@ -874,6 +899,8 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
874
899
  // End of Decoration
875
900
 
876
901
  public async destroy() {
902
+ // Flag synchronously so an in-flight load() bails before attaching frames.
903
+ this._destroyed = true;
877
904
  if (this._suspiciousActivityListener) {
878
905
  window.removeEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
879
906
  }
@@ -1158,7 +1185,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
1158
1185
  const entries = positions.filter(p => p.href === bare);
1159
1186
  if (!entries.length) return {};
1160
1187
  const atFragment = fragment
1161
- ? entries.find(p => p.locations.fragments[0] === fragment)
1188
+ ? entries.find(p => p.locations.fragments?.[0] === fragment)
1162
1189
  : undefined;
1163
1190
  let candidate: Locator | undefined;
1164
1191
  if (atFragment) {
@@ -1268,7 +1295,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
1268
1295
  return;
1269
1296
  }
1270
1297
 
1271
- const progression = locator?.locations?.progression;
1298
+ const progression = locator.locations?.progression;
1272
1299
  const hasProgression = progression && progression > 0;
1273
1300
  if(hasProgression)
1274
1301
  done = await new Promise<boolean>((res, _) => {
@@ -1279,7 +1306,42 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
1279
1306
  cb(done);
1280
1307
  }
1281
1308
 
1309
+ private completeLocator(locator: Locator): Locator {
1310
+ if(!locator.href) {
1311
+ let fellback = false;
1312
+ if(typeof locator.locations.position === "number") {
1313
+ const match = this.positions.find(p => p.locations.position === locator.locations.position);
1314
+ if (match) {
1315
+ locator = match.copyWithLocations(locator.locations);
1316
+ fellback = true;
1317
+ }
1318
+ }
1319
+ if(!fellback && typeof locator.locations?.totalProgression === "number") {
1320
+ // If locator has no href, but it does have a totalProgression,
1321
+ // we can attempt to find the right resource from the positions list.
1322
+ // This is here to help with conversion from OPDS locators which only
1323
+ // require the total progression in the publication.
1324
+ const targetProgression = locator.locations.totalProgression;
1325
+ let closestIdx = 0;
1326
+ let closestDist = Infinity;
1327
+ for (let i = 0; i < this.positions.length; i++) {
1328
+ const pos = this.positions[i];
1329
+ // Use totalProgression if available, otherwise estimate from index
1330
+ const posProg = pos.locations.totalProgression ?? (i / this.positions.length);
1331
+ const dist = Math.abs(posProg - targetProgression);
1332
+ if (dist < closestDist) {
1333
+ closestDist = dist;
1334
+ closestIdx = i;
1335
+ }
1336
+ }
1337
+ locator = this.positions[closestIdx].copyWithLocations(locator.locations);
1338
+ }
1339
+ }
1340
+ return locator;
1341
+ }
1342
+
1282
1343
  public go(locator: Locator, _: boolean, cb: (ok: boolean) => void): void {
1344
+ locator = this.completeLocator(locator);
1283
1345
  const href = locator.href.split("#")[0];
1284
1346
  let link = this.pub.readingOrder.findWithHref(href);
1285
1347
  if(!link) {
@@ -1297,6 +1359,10 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
1297
1359
  // Now that we've gone to the right locator, we can attach the listeners.
1298
1360
  // Doing this only at this stage reduces janky UI with multiple locator updates.
1299
1361
  this.attachListener();
1362
+ }).catch((error) => {
1363
+ console.error(error);
1364
+ this._isNavigating = false;
1365
+ cb(false);
1300
1366
  });
1301
1367
  }
1302
1368
 
@@ -1,12 +1,50 @@
1
- import { Link, MediaType, Publication, ReadingProgression } from "@readium/shared";
1
+ import { Link, MediaType, Publication, ReadingProgression, Resource } from "@readium/shared";
2
2
  import { Injector } from "../../injection/Injector.ts";
3
3
  import { getScriptMode } from "../../helpers/scriptMode.ts";
4
4
 
5
- const csp = (domains: string[]) => {
5
+ /** CSP source expressions cannot carry a query or fragment */
6
+ const cspSource = (url: string) => url.split(/[?#]/)[0];
7
+
8
+ /** A CSP path only prefix-matches when it ends in a slash; otherwise it must
9
+ * match exactly, which would allow the manifest and block every resource.
10
+ * See https://w3c.github.io/webappsec-csp/#match-paths */
11
+ const cspRootSource = (url: string) => {
12
+ const source = cspSource(url);
13
+ if (source.endsWith("/")) return source;
14
+ try {
15
+ const parsed = new URL(source);
16
+ parsed.pathname = parsed.pathname.substring(
17
+ 0,
18
+ parsed.pathname.lastIndexOf("/") + 1
19
+ );
20
+ return parsed.href;
21
+ } catch {
22
+ // Relative root, not parseable by `URL`
23
+ return source.replace(/[^/]*$/, "");
24
+ }
25
+ };
26
+
27
+ /**
28
+ * A flag to control whether `upgrade-insecure-requests` is included in the Content
29
+ * Security Policy of each spine-item blob frame:
30
+ *
31
+ * - `always` will always upgrade insecure requests
32
+ * - `never` will never upgrade insecure requests
33
+ * - `auto` will upgrade insecure requests only when the publication root URL is HTTPS
34
+ */
35
+ export type UpgradeInsecureRequests = 'always' | 'never' | 'auto';
36
+
37
+ const csp = (domains: string[], upgrade: UpgradeInsecureRequests = 'always', root?: string) => {
38
+ const includeUpgrade =
39
+ upgrade === "always" ||
40
+ (upgrade === "never"
41
+ ? false
42
+ : typeof root === "string" && root.startsWith("https:"));
43
+
6
44
  const d = domains.join(" ");
7
45
  return [
8
46
  // 'self' is useless because the document is loaded from a blob: URL
9
- `upgrade-insecure-requests`,
47
+ ...(includeUpgrade ? [`upgrade-insecure-requests`] : []),
10
48
  `default-src ${d} blob:`,
11
49
  `connect-src 'none'`, // No fetches to anywhere. TODO: change?
12
50
  `script-src ${d} blob: 'unsafe-inline'`, // JS scripts
@@ -20,73 +58,135 @@ const csp = (domains: string[]) => {
20
58
  ].join("; ");
21
59
  };
22
60
 
23
- export default class FrameBlobBuider {
24
- private readonly item: Link;
25
- private readonly burl: string;
26
- private readonly pub: Publication;
61
+ export default class FrameBlobBuilder {
27
62
  private readonly cssProperties?: { [key: string]: string };
28
63
  private readonly injector: Injector | null = null;
64
+ private readonly upgradeInsecureRequests: UpgradeInsecureRequests;
65
+
66
+ private currentUrl?: string;
67
+ private currentResource?: Resource;
68
+ private pendingBuild?: Promise<string>;
29
69
 
30
70
  constructor(
31
- pub: Publication,
32
- baseURL: string,
33
- item: Link,
71
+ private readonly pub: Publication,
72
+ private readonly baseURL: string,
73
+ private readonly item: Link,
34
74
  options: {
35
75
  cssProperties?: { [key: string]: string };
36
76
  injector?: Injector | null;
77
+ upgradeInsecureRequests?: UpgradeInsecureRequests;
37
78
  }
38
79
  ) {
39
- this.pub = pub;
40
80
  this.item = item;
41
- this.burl = item.toURL(baseURL) || "";
42
81
  this.cssProperties = options.cssProperties;
43
82
  this.injector = options.injector ?? null;
83
+ this.upgradeInsecureRequests = options.upgradeInsecureRequests ?? 'always';
84
+ }
85
+
86
+ public reset() {
87
+ this.currentUrl && URL.revokeObjectURL(this.currentUrl);
88
+ this.currentUrl = undefined;
89
+ this.currentResource?.close();
90
+ this.currentResource = undefined;
91
+ this.pendingBuild = undefined;
44
92
  }
45
93
 
46
94
  public async build(fxl = false): Promise<string> {
47
- if(!this.item.mediaType.isHTML) {
48
- if(this.item.mediaType.isBitmap || this.item.mediaType.equals(MediaType.SVG)) {
49
- return this.buildImageFrame();
95
+ if(this.currentUrl) return this.currentUrl;
96
+ if(this.pendingBuild) return this.pendingBuild;
97
+
98
+ const p = this.doBuild(fxl).finally(() => {
99
+ if(this.pendingBuild === p) this.pendingBuild = undefined;
100
+ });
101
+ this.pendingBuild = p;
102
+ return p;
103
+ }
104
+
105
+ private async doBuild(fxl: boolean): Promise<string> {
106
+ this.currentResource = this.pub.get(this.item);
107
+ const link = await this.currentResource.link();
108
+ if(!this.currentResource) {
109
+ // Reset has occured in the meantime
110
+ return "about:blank";
111
+ }
112
+ if(!link.mediaType.isHTML) {
113
+ if(link.mediaType.isBitmap || link.mediaType.equals(MediaType.SVG)) {
114
+ const blobUrl = await this.buildImageFrame();
115
+ this.currentUrl = blobUrl;
116
+ return blobUrl;
50
117
  } else
51
- throw Error("Unsupported frame mediatype " + this.item.mediaType.string);
118
+ throw Error("Unsupported frame mediatype " + link.mediaType.string);
52
119
  } else {
53
- return await this.buildHtmlFrame(fxl);
120
+ const blobUrl = await this.buildHtmlFrame(fxl);
121
+ this.currentUrl = blobUrl;
122
+ return blobUrl;
54
123
  }
55
124
  }
56
125
 
57
126
  private async buildHtmlFrame(fxl = false): Promise<string> {
58
- // Load the HTML resource
59
- const txt = await this.pub.get(this.item).readAsString();
60
- if(!txt) throw new Error(`Failed reading item ${this.item.href}`);
127
+ if(!this.currentResource) throw new Error("No resource loaded");
61
128
 
62
- const doc = new DOMParser().parseFromString(
63
- txt,
64
- this.item.mediaType.string as DOMParserSupportedType
65
- );
129
+ // Load the HTML resource
130
+ const link = await this.currentResource.link();
131
+ const doc = await this.currentResource.readAsXML() as HTMLDocument;
132
+ if(!doc) throw new Error(`Failed reading item ${link.href}`);
66
133
 
67
134
  const perror = doc.querySelector("parsererror");
68
135
  if (perror) {
69
136
  const details = perror.querySelector("div");
70
- throw new Error(`Failed parsing item ${this.item.href}: ${details?.textContent || perror.textContent}`);
137
+ throw new Error(`Failed parsing item ${link.href}: ${details?.textContent || perror.textContent}`);
71
138
  }
72
139
 
73
140
  // Apply resource injections if injection service is provided
74
141
  if (this.injector) {
75
- await this.injector.injectForDocument(doc, this.item);
142
+ await this.injector.injectForDocument(doc, link);
76
143
  }
77
144
 
78
- return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, fxl, this.cssProperties);
145
+ return this.finalizeDOM(doc, this.pub.baseURL, link.toURL(this.baseURL) || "", link.mediaType, fxl, this.cssProperties, this.upgradeInsecureRequests);
79
146
  }
80
147
 
81
- private buildImageFrame(): string {
82
- // Rudimentary image display
83
- const doc = document.implementation.createHTMLDocument(this.item.title || this.item.href);
148
+ private async buildImageFrame(): Promise<string> {
149
+ if(!this.currentResource) throw new Error("No resource loaded");
150
+ const link = await this.currentResource.link();
151
+ const burl = link.toURL(this.baseURL) || ""
152
+
153
+ // Rudimentary image display in an HTML doc
154
+ const doc = document.implementation.createHTMLDocument(link.title || link.href);
155
+
156
+ // Add viewport if available
157
+ if((link?.height || 0) > 0 && (link?.width || 0) > 0) {
158
+ const viewportMeta = doc.createElement("meta");
159
+ viewportMeta.name = "viewport";
160
+ viewportMeta.content = `width=${link.width}, height=${link.height}`;
161
+ viewportMeta.dataset.readium = "true";
162
+ doc.head.appendChild(viewportMeta);
163
+ }
164
+
84
165
  const simg = document.createElement("img");
85
- simg.src = this.burl || "";
86
- simg.alt = this.item.title || "";
87
- simg.decoding = "async";
166
+ simg.src = burl || "";
167
+ simg.alt = link.title || "";
168
+ await simg.decode(); // Reduce repaints
88
169
  doc.body.appendChild(simg);
89
- return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, true);
170
+
171
+ // Apply resource injections if injection service is provided
172
+ if (this.injector) {
173
+ await this.injector.injectForDocument(doc, new Link({
174
+ // Temporary solution to address injector only expecting (X)HTML
175
+ // documents for injection, which we are technically providing
176
+ href: "readium-image-frame.xhtml",
177
+ type: MediaType.XHTML.string
178
+ }));
179
+ }
180
+
181
+ // Add image style
182
+ const sstyle = doc.createElement("style");
183
+ sstyle.dataset.readium = "true";
184
+ sstyle.textContent = `
185
+ html, body { width: 100%; height: 100%; margin: 0; padding: 0; font-size: 0; }
186
+ img { margin: 0; padding: 0; border: 0; }`;
187
+ doc.head.appendChild(sstyle);
188
+
189
+ return this.finalizeDOM(doc, this.pub.baseURL, burl, link.mediaType, true, undefined, this.upgradeInsecureRequests);
90
190
  }
91
191
 
92
192
  private setProperties(cssProperties: { [key: string]: string }, doc: Document) {
@@ -96,15 +196,19 @@ export default class FrameBlobBuider {
96
196
  }
97
197
  }
98
198
 
99
- private finalizeDOM(doc: Document, root: string | undefined, base: string | undefined, mediaType: MediaType, fxl = false, cssProperties?: { [key: string]: string }): string {
199
+ private finalizeDOM(doc: Document, root: string | undefined, base: string | undefined, mediaType: MediaType, fxl = false, cssProperties?: { [key: string]: string }, upgradeInsecureRequests: UpgradeInsecureRequests = 'always'): string {
100
200
  if(!doc) return "";
101
201
 
102
- // Get allowed domains from injector if it exists
103
- const allowedDomains = this.injector?.getAllowedDomains?.() || [];
202
+ // Get allowed domains from injector if it exists. Host-configured, so
203
+ // only strip what CSP cannot express and leave their paths as given
204
+ const allowedDomains = (this.injector?.getAllowedDomains?.() || []).map(cspSource);
205
+
206
+ // The root must be a directory so the policy covers the whole publication
207
+ const rootSource = root ? cspRootSource(root) : undefined;
104
208
 
105
209
  // Always include the root domain if provided
106
210
  const domains = [...new Set([
107
- ...(root ? [root] : []),
211
+ ...(rootSource ? [rootSource] : []),
108
212
  ...allowedDomains
109
213
  ])].filter(Boolean);
110
214
 
@@ -124,6 +228,7 @@ export default class FrameBlobBuider {
124
228
  // loaded in parallel, greatly increasing overall speed.
125
229
  doc.body.querySelectorAll("img").forEach((img) => {
126
230
  img.setAttribute("fetchpriority", "high");
231
+ img.setAttribute("referrerpolicy", "origin");
127
232
  });
128
233
 
129
234
  // We need to ensure that lang is set on the root element
@@ -180,7 +285,7 @@ export default class FrameBlobBuider {
180
285
  // Add CSP with allowed domains
181
286
  const meta = doc.createElement("meta");
182
287
  meta.httpEquiv = "Content-Security-Policy";
183
- meta.content = csp(domains);
288
+ meta.content = csp(domains, upgradeInsecureRequests, root);
184
289
  meta.dataset.readium = "true";
185
290
  doc.head.firstChild!.before(meta);
186
291
 
@@ -14,19 +14,22 @@ interface RegistryValue {
14
14
  cb: CommsAck;
15
15
  }
16
16
  const REGISTRY_EXPIRY = 10000; // 10 seconds max
17
+ const GC_INTERVAL = 5000;
17
18
 
18
19
  export type FrameCommsListener = (key: CommsEventKey | ManagerEventKey, value: unknown) => void;
19
20
 
20
21
  export class FrameComms {
21
22
  private readonly wnd: Window;
22
23
  private readonly registry = new Map<string, RegistryValue>();
23
- private readonly gc: ReturnType<typeof setInterval>;
24
+ private gc: ReturnType<typeof setInterval> | undefined;
24
25
  // @ts-ignore
25
26
  private readonly origin: string;
26
27
  public readonly channelId: string;
27
28
  private _ready = false;
28
29
  private _listener: FrameCommsListener | undefined;
29
30
  private listenerBuffer: [key: CommsEventKey, value: unknown][] = [];
31
+ private visibleTime = 0;
32
+ private visibleSince: number | undefined = document.hidden ? undefined : performance.now();
30
33
 
31
34
  public set listener(listener: FrameCommsListener) {
32
35
  if(this.listenerBuffer.length > 0)
@@ -46,28 +49,60 @@ export class FrameComms {
46
49
  } catch (error) {
47
50
  this.channelId = mid();
48
51
  }
52
+ this.startGc();
53
+ window.addEventListener("message", this.handler);
54
+ this.send("_ping", undefined);
55
+ }
56
+
57
+ private startGc() {
58
+ if (this.gc !== undefined) return;
59
+ this.handleVisibility();
60
+ document.addEventListener("visibilitychange", this.visibilityHandler);
49
61
  this.gc = setInterval(() => {
62
+ const now = this.visibleNow();
50
63
  this.registry.forEach((v, k) => {
51
- if (performance.now() - v.time > REGISTRY_EXPIRY) {
64
+ if (now - v.time > REGISTRY_EXPIRY) {
52
65
  console.warn(k, "event for", v.key, "was never handled!");
53
66
  this.registry.delete(k);
54
67
  }
55
68
  });
56
- }, 5000);
57
- window.addEventListener("message", this.handler);
58
- this.send("_ping", undefined);
69
+ }, GC_INTERVAL);
59
70
  }
60
71
 
72
+ private stopGc() {
73
+ this.visibleTime = this.visibleNow();
74
+ this.visibleSince = undefined;
75
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
76
+ clearInterval(this.gc);
77
+ this.gc = undefined;
78
+ }
79
+
80
+ // Frames ack some commands from requestAnimationFrame, which doesn't run while the page is hidden
81
+ private visibleNow() {
82
+ return this.visibleTime + (this.visibleSince === undefined ? 0 : performance.now() - this.visibleSince);
83
+ }
84
+
85
+ private handleVisibility() {
86
+ if (document.hidden) {
87
+ this.visibleTime = this.visibleNow();
88
+ this.visibleSince = undefined;
89
+ } else if (this.visibleSince === undefined) {
90
+ this.visibleSince = performance.now();
91
+ }
92
+ }
93
+ private visibilityHandler = this.handleVisibility.bind(this);
94
+
61
95
  public halt() {
62
96
  this._ready = false;
63
97
  window.removeEventListener("message", this.handler);
64
- clearInterval(this.gc);
98
+ this.stopGc();
65
99
  this._listener = undefined;
66
100
  this.registry.clear();
67
101
  }
68
102
 
69
103
  public resume() {
70
104
  window.addEventListener("message", this.handler);
105
+ this.startGc();
71
106
  this._ready = true;
72
107
  }
73
108
 
@@ -122,7 +157,7 @@ export class FrameComms {
122
157
  this.registry.set(id, {
123
158
  // Add callback to the registry
124
159
  cb: callback,
125
- time: performance.now(),
160
+ time: this.visibleNow(),
126
161
  key,
127
162
  });
128
163
  this.wnd.postMessage(
@@ -4,6 +4,7 @@ import type { ReadiumWindow } from "@readium/navigator-html-injectables";
4
4
  import { sML } from "@readium/helpers";
5
5
  import type { IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../../Navigator.ts";
6
6
  import { KeyboardConditionBridge } from "../../peripherals/KeyboardConditionBridge.ts";
7
+ import { createSerializer } from "../../helpers/serialize.ts";
7
8
 
8
9
 
9
10
  export class FrameManager {
@@ -19,6 +20,8 @@ export class FrameManager {
19
20
  private pendingUnwatchSelectors: string[] = [];
20
21
  private conditionBridge?: KeyboardConditionBridge;
21
22
  private currModules: ModuleName[] = [];
23
+ // Halting comms drops pending acks, so an operation must not start while another awaits one
24
+ private readonly serialize = createSerializer();
22
25
 
23
26
  constructor(
24
27
  source: string,
@@ -31,7 +34,7 @@ export class FrameManager {
31
34
  this.frame.sandbox.value = "allow-same-origin allow-scripts";
32
35
  this.frame.classList.add("readium-navigator-iframe");
33
36
  this.frame.style.visibility = "hidden";
34
- this.frame.style.setProperty("aria-hidden", "true");
37
+ this.frame.ariaHidden = "true";
35
38
  this.frame.style.opacity = "0";
36
39
  this.frame.style.position = "absolute";
37
40
  this.frame.style.pointerEvents = "none";
@@ -46,7 +49,7 @@ export class FrameManager {
46
49
  }
47
50
 
48
51
  async load(modules: ModuleName[]): Promise<Window> {
49
- return new Promise((res, rej) => {
52
+ return this.serialize(() => new Promise((res, rej) => {
50
53
  if(this.loader) {
51
54
  const wnd = this.frame.contentWindow!;
52
55
  // Check if currently loaded modules are equal
@@ -72,7 +75,7 @@ export class FrameManager {
72
75
  try { rej(err); } catch (error) {}
73
76
  }
74
77
  this.frame.contentWindow!.location.replace(this.source);
75
- });
78
+ }));
76
79
  }
77
80
 
78
81
  private applyContentProtection() {
@@ -132,17 +135,23 @@ export class FrameManager {
132
135
  }
133
136
 
134
137
  async destroy() {
135
- this.conditionBridge?.destroy();
136
- await this.hide();
137
- this.loader?.destroy();
138
- this.frame.remove();
139
- this.destroyed = true;
138
+ return this.serialize(async () => {
139
+ this.conditionBridge?.destroy();
140
+ await this.hideNow();
141
+ this.loader?.destroy();
142
+ this.frame.remove();
143
+ this.destroyed = true;
144
+ });
140
145
  }
141
146
 
142
147
  async hide(): Promise<void> {
148
+ return this.serialize(() => this.hideNow());
149
+ }
150
+
151
+ private async hideNow(): Promise<void> {
143
152
  if(this.destroyed) return;
144
153
  this.frame.style.visibility = "hidden";
145
- this.frame.style.setProperty("aria-hidden", "true");
154
+ this.frame.ariaHidden = "true";
146
155
  this.frame.style.opacity = "0";
147
156
  this.frame.style.pointerEvents = "none";
148
157
  this.hidden = true;
@@ -162,6 +171,10 @@ export class FrameManager {
162
171
  }
163
172
 
164
173
  async show(atProgress?: number): Promise<void> {
174
+ return this.serialize(() => this.showNow(atProgress));
175
+ }
176
+
177
+ private async showNow(atProgress?: number): Promise<void> {
165
178
  if(this.destroyed) throw Error("Trying to show frame when it doesn't exist");
166
179
  if(!this.frame.parentElement) throw Error("Trying to show frame that is not attached to the DOM");
167
180
  if(this.comms) this.comms.resume();
@@ -178,9 +191,9 @@ export class FrameManager {
178
191
 
179
192
  const remove = () => {
180
193
  this.frame.style.removeProperty("visibility");
181
- this.frame.style.removeProperty("aria-hidden");
182
194
  this.frame.style.removeProperty("opacity");
183
195
  this.frame.style.removeProperty("pointer-events");
196
+ this.frame.ariaHidden = null;
184
197
  this.hidden = false;
185
198
 
186
199
  if (sML.UA.WebKit) {
@@ -202,14 +215,12 @@ export class FrameManager {
202
215
  setCSSProperties(properties: { [key: string]: string }) {
203
216
  if(this.destroyed || !this.frame.contentWindow) return;
204
217
 
205
- // We need to resume and halt postMessage to update the properties
206
- // if the frame is hidden since it’s been halted in hide()
207
- if (this.hidden) {
208
- if (this.comms) this.comms?.resume();
209
- else this.comms = new FrameComms(this.frame.contentWindow!, this.source);
218
+ // A halted comms can still send, so only a never-shown hidden frame needs one created
219
+ if (this.hidden && !this.comms) {
220
+ this.comms = new FrameComms(this.frame.contentWindow, this.source);
221
+ this.comms.halt();
210
222
  }
211
223
  this.comms?.send("update_properties", properties);
212
- if (this.hidden) this.comms?.halt();
213
224
  }
214
225
 
215
226
  get iframe() {