-
Notifications
You must be signed in to change notification settings - Fork 2
/
web-view.service-host.ts
1354 lines (1224 loc) · 57.1 KB
/
web-view.service-host.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Service that handles WebView-related operations
*
* Don't expose this whole service on papi, just specific operations. The remaining exports are only
* for services in the renderer to call.
*/
import cloneDeep from 'lodash/cloneDeep';
import {
AsyncVariable,
Unsubscriber,
deserialize,
serialize,
isString,
newGuid,
indexOf,
substring,
startsWith,
split,
} from 'platform-bible-utils';
import { newNonce } from '@shared/utils/util';
import { createNetworkEventEmitter } from '@shared/services/network.service';
import {
GetWebViewOptions,
SavedWebViewDefinition,
WebViewContentType,
WebViewDefinition,
WebViewDefinitionReact,
WebViewDefinitionUpdateInfo,
WebViewId,
WebViewType,
WEBVIEW_DEFINITION_UPDATABLE_PROPERTY_KEYS,
SAVED_WEBVIEW_DEFINITION_OMITTED_KEYS,
SavedWebViewDefinitionOmittedKeys,
} from '@shared/models/web-view.model';
import {
Layout,
OnLayoutChangeRCDock,
PapiDockLayout,
SavedTabInfo,
TabInfo,
WebViewTabProps,
} from '@shared/models/docking-framework.model';
import webViewProviderService from '@shared/services/web-view-provider.service';
import { LayoutBase } from 'rc-dock';
import logger from '@shared/services/logger.service';
import LogError from '@shared/log-error.model';
import memoizeOne from 'memoize-one';
import {
AddWebViewEvent,
EVENT_NAME_ON_DID_ADD_WEB_VIEW,
EVENT_NAME_ON_DID_UPDATE_WEB_VIEW,
NETWORK_OBJECT_NAME_WEB_VIEW_SERVICE,
UpdateWebViewEvent,
WebViewServiceType,
} from '@shared/services/web-view.service-model';
import networkObjectService from '@shared/services/network-object.service';
import {
getFullWebViewStateById,
setFullWebViewStateById,
} from '@renderer/services/web-view-state.service';
import { registerCommand } from '@shared/services/command.service';
import { CommandNames } from 'papi-shared-types';
import {
type SettingsTabData,
TAB_TYPE_PROJECT_SETTINGS_TAB,
TAB_TYPE_USER_SETTINGS_TAB,
} from '@renderer/components/settings-tabs/settings-tab.component';
/** Emitter for when a webview is added */
const onDidAddWebViewEmitter = createNetworkEventEmitter<AddWebViewEvent>(
EVENT_NAME_ON_DID_ADD_WEB_VIEW,
);
/** Event that emits with webView info when a webView is added */
export const onDidAddWebView = onDidAddWebViewEmitter.event;
/** Emitter for when a webview is updated */
const onDidUpdateWebViewEmitter = createNetworkEventEmitter<UpdateWebViewEvent>(
EVENT_NAME_ON_DID_UPDATE_WEB_VIEW,
);
/** Event that emits with webView info when a webView is added */
export const onDidUpdateWebView = onDidUpdateWebViewEmitter.event;
// #region Security
/**
* The iframe [sandbox attribute]
* (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox) that determines if
* scripts are allowed to run on an iframe
*/
export const IFRAME_SANDBOX_ALLOW_SCRIPTS = 'allow-scripts';
/**
* The iframe [sandbox attribute]
* (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox) that determines if an
* iframe is allowed to interact with its parent as a same-origin website. The iframe must still be
* on the same origin as its parent in order to interact same-origin.
*/
export const IFRAME_SANDBOX_ALLOW_SAME_ORIGIN = 'allow-same-origin';
/**
* The iframe [sandbox attribute]
* (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox) that determines if an
* iframe is allowed to open separate windows with window.open and anchor tags with
* `target="_blank"`. Note that we have a `setWindowOpenHandler` in `main.ts` that causes these to
* be opened in the default browser
*/
export const IFRAME_SANDBOX_ALLOW_POPUPS = 'allow-popups';
/**
* The only `sandbox` attribute values we allow iframes with `src` to have including URL WebView
* iframes. These are separate than iframes with `srcdoc` for a few reasons:
*
* - These iframes cannot be on the same origin as the parent window even if `allow-same-origin` is
* present (unless they are literally on the same origin) because we do not allow `frame-src
* blob:`
* - `src` iframes do not inherit the CSP of their parent window.
* - We are not able to modify the `srcdoc` before inserting it to ensure it has a CSP that we control
* to attempt to prevent arbitrary code execution on same origin. We are trusting the browser's
* ability to create a strong and safe boundary between parent and child iframe in different
* origin.
*
* TODO: consider using `csp` attribute on iframe to mitigate this issue
* - Extension developers do not know what code they are executing if they use some random URL in
* `src` WebViews.
*
* The `sandbox` attribute controls what privileges iframe scripts and other things have:
*
* - `allow-same-origin` so the iframe can access the storage APIs (localstorage, cookies, etc) and
* other same-origin connections for its own origin. `blob:` iframes are considered part of the
* parent origin, but we block them with the CSP in `index.ejs`. For more information, see
* https://web.dev/articles/sandboxed-iframes
* - `allow-scripts` so the iframe can actually do things. Defaults to not present since src iframes
* can get scripts from anywhere. Extension developers should only enable this if needed as this
* increases the possibility of a security threat occurring. Defaults to false
* - `allow-popups` so the iframe can open separate windows with window.open and anchor tags with
* `target="_blank"`. Note that we have a `setWindowOpenHandler` in `main.ts` that causes these to
* be opened in the default browser
*
* DO NOT CHANGE THIS WITHOUT A SERIOUS REASON
*
* Note: Mozilla's iframe page warns that listing both 'allow-same-origin' and 'allow-scripts'
* allows the child scripts to remove this sandbox attribute from the iframe. This should only be
* possible on iframes that are on the same origin as the parent including those that use `srcdoc`
* to define their HTML code. We monkey-patch `document.createElement` to prevent child iframes from
* creating new iframes and also use a `MutationObserver` in `web-view.service.ts` to remove any
* iframes that do not comply with these sandbox requirements. This successfully prevents iframes
* with too many privileges from executing as of July 2023. However, this means the sandboxing could
* do nothing for a determined hacker if they ever find a way around all this. We must distrust the
* whole renderer due to this issue. We will probably want to stay vigilant on security in this
* area.
*/
const ALLOWED_IFRAME_SRC_SANDBOX_VALUES = [
IFRAME_SANDBOX_ALLOW_SAME_ORIGIN,
IFRAME_SANDBOX_ALLOW_SCRIPTS,
IFRAME_SANDBOX_ALLOW_POPUPS,
];
/**
* The minimal `src` WebView iframe sandboxing. This is applied to WebView iframes that use `src` in
* `web-view.component.tsx`. See {@link ALLOWED_IFRAME_SRC_SANDBOX_VALUES} for more information on
* our sandboxing methods and why we chose these values.
*
* Note: 'allow-same-origin' and 'allow-scripts' are not included here because they are added
* conditionally depending on the WebViewDefinition in `web-view.component.tsx`
*/
export const WEBVIEW_IFRAME_SRC_SANDBOX = ALLOWED_IFRAME_SRC_SANDBOX_VALUES.filter(
(value) =>
value !== IFRAME_SANDBOX_ALLOW_SCRIPTS &&
value !== IFRAME_SANDBOX_ALLOW_SAME_ORIGIN &&
value !== IFRAME_SANDBOX_ALLOW_POPUPS,
).join(' ');
/**
* The only `sandbox` attribute values we allow iframes with `srcdoc` to have including HTML and
* React WebView iframes. These are separate than iframes with `src` for a few reasons:
*
* - These iframes will be on the same origin as the parent window if `allow-same-origin` is present.
* This is very serious and demands significant security risk consideration.
* - `srcdoc` iframes inherit the CSP of their parent window (in our case, `index.ejs`)
* - We are modifying the `srcdoc` before inserting it to ensure it has a CSP that we control to
* attempt to prevent unintended code execution on same origin
* - Extension developers should know exactly what code they're running in `srcdoc` WebViews, whereas
* they could include some random URL in `src` WebViews
*
* TODO: consider requiring `srcdoc` WebView content to come directly from `papi-extension://`
* instead of assuming extension developers will bundle their WebView code? This would mean the
* only code that runs on same origin is code that extension developers definitely included in
* their extension bundle https://github.com/paranext/paranext-core/issues/604
*
* The `sandbox` attribute controls what privileges iframe scripts and other things have:
*
* - `allow-same-origin` so the iframe can get papi and communicate and such
* - `allow-scripts` so the iframe can actually do things
* - `allow-popups` so the iframe can open separate windows with window.open and anchor tags with
* `target="_blank"`. Note that we have a `setWindowOpenHandler` in `main.ts` that causes these to
* be opened in the default browser
*
* DO NOT CHANGE THIS WITHOUT A SERIOUS REASON
*
* Note: Mozilla's iframe page warns that listing both 'allow-same-origin' and 'allow-scripts'
* allows the child scripts to remove this sandbox attribute from the iframe. This should only be
* possible on iframes that are on the same origin as the parent including those that use `srcdoc`
* to define their HTML code. We monkey-patch `document.createElement` to prevent child iframes from
* creating new iframes and also use a `MutationObserver` in `web-view.service.ts` to remove any
* iframes that do not comply with these sandbox requirements. This successfully prevents iframes
* with too many privileges from executing as of July 2023. However, this means the sandboxing could
* do nothing for a determined hacker if they ever find a way around all this. We must distrust the
* whole renderer due to this issue. We will probably want to stay vigilant on security in this
* area.
*/
export const ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES = [...ALLOWED_IFRAME_SRC_SANDBOX_VALUES];
/**
* The minimal `srcdoc` WebView iframe sandboxing. This is applied to WebView iframes that use
* `srcDoc` in `web-view.component.tsx`. See {@link ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES} for more
* information on our sandboxing methods and why we chose these values.
*
* Note: 'allow-same-origin' and 'allow-scripts' are not included here because they are added
* conditionally depending on the WebViewDefinition in `web-view.component.tsx`
*/
export const WEBVIEW_IFRAME_SRCDOC_SANDBOX = ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES.filter(
(value) =>
value !== IFRAME_SANDBOX_ALLOW_SCRIPTS &&
value !== IFRAME_SANDBOX_ALLOW_SAME_ORIGIN &&
value !== IFRAME_SANDBOX_ALLOW_POPUPS,
).join(' ');
/**
* Get Regex to test stack traces against for creating script and iframe tags on the renderer
* document. Only renderer code is allowed to create script and iframe tags. script and iframe tags
* coming from any other source throw an error.
*
* Note that sourceURLs can't have spaces in them, so we explicitly test for a space before the
* source so bad actors can't put these special words into their sourceURL
*/
/* In development, safe errors look like this:
Error
at document.createElement (http://localhost/renderer.dev.js...)
at __webpack_require__.l (http://localhost/renderer.dev.js...)
...
*/
/* In development, bad errors look more like this:
Error
at document.createElement (http://localhost/renderer.dev.js...)
at evil.web-view.htmlfile://app.asar
*/
/* In production, safe errors look like this:
Error
at Qt.document.createElement (file:///C:/Users/app.asar/dist/renderer/renderer.js...)
at i.l (file:///C:/Users/app.asar/dist/renderer/renderer.js...)
...
*/
/* In production, bad errors look more like this:
Error
at Qt.document.createElement (file:///C:/Users/app.asar/dist/renderer/stuffnthings)
at evil.web-view.htmlfile://app.asar
*/
const getRendererScriptRegex = memoizeOne(() =>
globalThis.isPackaged
? /^.+\s+.+ \S*document\.createElement \(file:\/\/\S*app.asar\/dist\/renderer\/renderer\.js\S*\)\s+.+ \(file:\/\/\S*app.asar\/dist\/renderer\/renderer\.js\S*\)/
: /^.+\s+.+ \S*document\.createElement \(https?:\/\/\S*\/renderer\.dev\.js\S*\)\s+.+ \(https?:\/\/\S*\/renderer\.dev\.js\S*\)/,
);
/**
* The HTML tags that are not allowed at all in the main renderer window. Our MutationObserver
* deletes these immediately if it sees them.
*
* WARNING: These are all untested. The MutationObserver was not fast enough to remove script tags
* before they executed code, so there is some chance these could do bad things too.
*
* TODO: Test these sometime
*/
// Maybe we don't actually need this... Maybe we should evaluate if we want this.
// Would lag things up if we changed our MutationObserver to use getElementsByTagName
const FORBIDDEN_HTML_TAGS = ['object', 'embed', 'frame', 'frameset'];
/**
* The HTML tags that are only allowed in the main renderer window if created by the renderer. Our
* monkey-patch on `document.createElement` protects these.
*
* Technically, all elements should really be created only by the renderer, but we must choose the
* security-related ones to guard closely since this is an inefficient check.
*
* Note: this only applies to tags added to the document after initial load, so the document
* metadata tags are not normally hit.
*
* WARNING: A stack trace has to be created each time any of these are created, so it is not very
* efficient when one of these tags is created. Please avoid using these tags where possible.
*/
const RESTRICTED_HTML_TAGS = [
// All the [Document metadata](https://developer.mozilla.org/en-US/docs/Web/HTML/Element#document_metadata)
// tags except `style` because honestly there are just too many of them. They flood the logs and
// took 100ms on reload. If it becomes an issue, we can worry about it then. Maybe we can try
// checking for style when the first WebView is loaded in or something
'base',
'head',
'link',
'meta',
// See comment above for why not style
// 'style',
'title',
// The [Sectioning root](https://developer.mozilla.org/en-US/docs/Web/HTML/Element#sectioning_root)
'body',
// Tags that have [href](https://www.w3schools.com/tags/att_href.asp) for navigating
'a',
'area',
// Can navigate
'form',
// Don't want to let extensions block the UI
'dialog',
// Very dangerous tags that we need to be careful to restrict - we do not want extension code to
// run in renderer context
'script',
'iframe',
// Weird tag to preview a site that we probably don't need
'portal',
];
/**
* Checks a node and its children recursively to determine if they are forbidden and removes them
* from the dom if so.
*
* @param node The node to check recursively
* @param parent Node from which to remove this node if it is forbidden
*/
function removeNodeIfForbidden(node: Node) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
// This is an element node.
// eslint-disable-next-line no-type-assertion/no-type-assertion
const element = node as Element;
/** Remove the element */
const removeElement = (info: string) => {
logger.warn(
`${info} rejected! An extension may have been trying to execute code with higher privileges!`,
);
element.remove();
};
function validateElementThenChildren(currentElement: Element) {
const currentTag = currentElement.tagName.toLowerCase();
// If the element is forbidden, remove this whole tree
if (currentTag === 'iframe') {
const sandbox = currentElement.attributes.getNamedItem('sandbox');
if (!sandbox) {
removeElement('iframe with no sandbox');
return;
}
if (!isString(sandbox.value)) {
removeElement(`iframe with a non-string sandbox value ${sandbox.value}`);
return;
}
const sandboxValues = split(sandbox.value, ' ');
const src = currentElement.attributes.getNamedItem('src');
// If the iframe has `src`, only allow `src` sandbox values because browsers that do not
// support `srcdoc` fall back to `src` so we should be more strict
const allowedSandboxValues = src
? ALLOWED_IFRAME_SRC_SANDBOX_VALUES
: ALLOWED_IFRAME_SRCDOC_SANDBOX_VALUES;
if (
sandboxValues.some(
(sandboxValue) => sandboxValue !== '' && !allowedSandboxValues.includes(sandboxValue),
)
) {
removeElement(
`iframe with \`${
src ? 'src' : 'srcdoc'
}\` attribute and disallowed sandbox attribute value '${sandbox.value}'`,
);
return;
}
}
if (FORBIDDEN_HTML_TAGS.includes(currentTag)) {
removeElement(currentTag);
return;
}
// Check the element's children to see if they are forbidden
for (let i = 0; i < currentElement.children?.length; i++) {
validateElementThenChildren(currentElement.children[i]);
}
}
// Validate the new element and all children recursively. If anything is forbidden, the top
// element will be removed
validateElementThenChildren(element);
}
/**
* Reads through the list of document changes detected by our MutationObserver and deletes forbidden
* elements including iframes with improper sandboxing
*/
function removeForbiddenElements(mutationList: MutationRecord[]) {
// If this becomes too slow, it may be necessary to use getElementsByTagName instead of looping
// through the mutations. Thanks for the idea to https://stackoverflow.com/a/39332340
mutationList.forEach((m) => {
// If `src` or `srcdoc` attributes changed, validate the element
if (m.type === 'attributes') {
if (!m.target.parentNode) {
logger.warn(
`MutationObserver couldn't find parent for node that changed attributes! This doesn't make sense. Investigate`,
m.target,
);
}
removeNodeIfForbidden(m.target);
return;
}
// If for some reason this mutation is not added or removed nodes, forget it
if (m.type !== 'childList') return;
// Check if each added node is a forbidden element
m.addedNodes.forEach((node) => removeNodeIfForbidden(node));
});
}
// #endregion
// #region Dock layouts
/** `localstorage` key for saving and loading the dock layout */
const DOCK_LAYOUT_KEY = 'dock-saved-layout';
/** Create a new dock layout promise variable */
function createDockLayoutAsyncVar(): AsyncVariable<PapiDockLayout> {
return new AsyncVariable<PapiDockLayout>('web-view.service-host.platformDockLayout');
}
/**
* WARNING: DO NOT USE THIS VARIABLE DIRECTLY. USE `getDockLayout()`
*
* Asynchronously accessed variable that will hold the rc-dock dock layout along with a couple other
* props. This is populated by `platform-dock-layout.component.tsx` registering its dock layout with
* this service, allowing this service to manage layouts and such.
*
* Do not save this variable out anywhere because it can change, invalidating the old one (see
* `registerDockLayout`)
*/
let papiDockLayoutVar = createDockLayoutAsyncVar();
/**
* WARNING: DO NOT USE THIS VARIABLE DIRECTLY. USE `getDockLayoutSync()`
*
* Synchronously accessed variable that will hold the rc-dock dock layout along with a couple other
* props. This is populated by `platform-dock-layout.component.tsx` registering its dock layout with
* this service, allowing this service to manage layouts and such.
*
* Do not save this variable out anywhere because it can change, invalidating the old one (see
* `registerDockLayout`)
*/
let papiDockLayoutVarSync: PapiDockLayout | undefined;
/**
* Get the papi dock layout promise. It will resolve to the papi dock layout when it is registered.
*
* Do not save the returned variable out anywhere because it can change, invalidating the old one
* (see `registerDockLayout`)
*
* @returns Promise that resolves to the papi dock layout
*/
function getDockLayout(): Promise<PapiDockLayout> {
return papiDockLayoutVar.promise;
}
/**
* Get the papi dock layout synchronously _assuming_ it has been registered. This should be safe to
* assume if you are accessing this from inside a tab's code
*
* Do not save the returned variable out anywhere because it can change, invalidating the old one
* (see `registerDockLayout`)
*
* @returns The papi dock layout
* @throws If the papi dock layout has not been registered
*/
function getDockLayoutSync(): PapiDockLayout {
if (!papiDockLayoutVarSync)
throw new Error(
'WebView Service error: Dock layout was requested synchronously, but the dock layout has not been registered!',
);
return papiDockLayoutVarSync;
}
/**
* Set the papi dock layout (async and sync). Resolves `getDockLayout()` calls.
*
* This should very likely only be used in `registerDockLayout`.
*
* @param dockLayout The papi dock layout to set or undefined to reset the dock layout
*/
function setDockLayout(dockLayout: PapiDockLayout | undefined): void {
if (dockLayout === undefined) {
// Create a new async var to empty out the dock layout only if the dock layout was previously
// set. That way, async callers to the dock layout who are awaiting a resolved value don't get
// lost or rejected needlessly
// TODO: Would creating a new async var create any problems...? I guess only if someone saves
// dockLayoutVar somewhere else
if (papiDockLayoutVar.hasSettled) papiDockLayoutVar = createDockLayoutAsyncVar();
papiDockLayoutVarSync = undefined;
} else {
// Set the dock layout as the promise var. Throws if already resolved
papiDockLayoutVar.resolveToValue(dockLayout, true);
if (papiDockLayoutVarSync)
throw new Error(
'WebView Service error: papiDockLayoutVarSync is already set when trying to set it!',
);
papiDockLayoutVarSync = dockLayout;
}
}
/**
* When rc-dock detects a changed layout, save it. This function is given to the registered
* papiDockLayout to run when the dock layout changes.
*
* @param newLayout The changed layout to save.
*/
// TODO: We could filter whether we need to save based on the `direction` argument. - IJH 2023-05-1
const onLayoutChange: OnLayoutChangeRCDock = async (newLayout) => {
return saveLayout(newLayout);
};
/**
* Loads layout information into the dock layout.
*
* @param layout If this parameter is provided, loads that layout information. If not provided, gets
* the persisted layout information and loads it into the dock layout.
*/
async function loadLayout(layout?: LayoutBase): Promise<void> {
const dockLayoutVar = await getDockLayout();
const layoutToLoad = layout || getStorageValue(DOCK_LAYOUT_KEY, dockLayoutVar.testLayout);
dockLayoutVar.dockLayout.loadLayout(layoutToLoad);
if (layout) {
// A layout was provided, meaning this is a layout change. Since `dockLayout.loadLayout` doesn't
// run `onLayoutChange`, we run it manually
await onLayoutChange(layoutToLoad);
}
}
/**
* Safely load a value from local storage.
*
* @param key Of the value.
* @param defaultValue To return if the key is not found.
* @returns The value of the key fetched from local storage, or the default value if not found.
*/
function getStorageValue<T>(key: string, defaultValue: T): T {
const saved = localStorage.getItem(key);
const initial = saved ? deserialize(saved) : undefined;
return initial || defaultValue;
}
/**
* Persists the current dock layout information.
*
* @param layout Layout to persist
*/
async function saveLayout(layout: LayoutBase): Promise<void> {
const currentLayout = layout;
localStorage.setItem(DOCK_LAYOUT_KEY, serialize(currentLayout));
}
/**
* Register a dock layout React element to be used by this service to perform layout-related
* operations
*
* @param dockLayout Dock layout element to register along with other important properties
* @returns Function used to unregister this dock layout
*/
export function registerDockLayout(dockLayout: PapiDockLayout): Unsubscriber {
// Save the current async var so we know if it changed before we unsubscribed
const currentPapiDockLayoutVar = papiDockLayoutVar;
setDockLayout(dockLayout);
// TODO: Strange pattern that we are setting a ref to a service function. Investigate changing
// this pattern in some way. Maybe just export `onLayoutChange`?
dockLayout.onLayoutChangeRef.current = onLayoutChange;
// Will we ever need to await this? For now, seems like it unnecessarily complicates registering
// because making this function async would probably be annoying in React
loadLayout();
// Return an unsubscriber to unregister this dock layout. The primary situation in which I see
// this happening is when you change something on the renderer that causes a live hot reload
return () => {
// Somehow this is not the registered dock layout anymore
if (papiDockLayoutVar !== currentPapiDockLayoutVar)
throw new Error('Tried to unregister an old dock layout');
setDockLayout(undefined);
return true;
};
}
// #endregion
// #region Tabs
/**
* Add or update a tab in the layout
*
* @param savedTabInfo Info for tab to add or update
* @param layout Information about where to put a new tab
* @returns If tab added, final layout used to display the new tab. If existing tab updated,
* `undefined`
*/
export const addTab = async <TData = unknown>(
savedTabInfo: SavedTabInfo & { data?: TData },
layout: Layout,
): Promise<Layout | undefined> => {
return (await getDockLayout()).addTabToDock(savedTabInfo, layout);
};
/**
* Remove a tab in the layout
*
* @param tabId ID of the tab to remove
* @returns True if successfully found the tab to remove
*/
export const removeTab = async (tabId: string): Promise<boolean> => {
return (await getDockLayout()).removeTabFromDock(tabId);
};
/**
* Basic `saveTabInfo` that simply strips the properties added by {@link TabInfo} off of the object
* and returns it as a {@link SavedTabInfo}. Runs as the {@link TabSaver} by default if the tab type
* does not have a specific `TabSaver`
*/
export function saveTabInfoBase(tabInfo: TabInfo): SavedTabInfo {
// We don't need to use the other properties, but we need to remove them
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { tabTitle, tabTooltip, tabIconUrl, content, minWidth, minHeight, ...savedTabInfo } =
tabInfo;
return savedTabInfo;
}
// #endregion
// #region Web view definitions
/**
* Updates the WebView with the specified ID with the specified properties and sends an update event
*
* @param webViewId The ID of the WebView to update
* @param webViewDefinitionUpdateInfo Properties to update on the WebView. Any unspecified
* properties will stay the same
* @returns True if successfully found the WebView to update and actually updated any properties;
* false otherwise
* @throws If the papi dock layout has not been registered
*/
export function updateWebViewDefinitionSync(
webViewId: string,
webViewDefinitionUpdateInfo: WebViewDefinitionUpdateInfo,
): boolean {
const didUpdateWebView = getDockLayoutSync().updateWebViewDefinition(
webViewId,
webViewDefinitionUpdateInfo,
);
if (didUpdateWebView) {
const webView = getSavedWebViewDefinitionSync(webViewId);
if (!webView) {
logger.warn(
`Did not find a web view for id ${webViewId} immediately after updating that web view. Investigate`,
);
} else
onDidUpdateWebViewEmitter.emit({
webView,
});
}
return didUpdateWebView;
}
/**
* Merges web view definition updates into a web view definition. Does not modify the original web
* view definition but returns a new object.
*
* Please note that this method returns `undefined` if and only if no properties updated (properties
* are compared by simple reference equality ===).
*
* @param webViewDefinition Web view definition to merge into
* @param updateInfo Updates to merge into the web view definition
* @returns New copy of web view definition with updates applied OR `undefined` IF NO PROPERTIES
* WERE UPDATED
*/
export function mergeUpdatablePropertiesIntoWebViewDefinitionIfChangesArePresent<
T extends SavedWebViewDefinition,
>(webViewDefinition: T, updateInfo: WebViewDefinitionUpdateInfo): T | undefined {
let didUpdateAnyProperties = false;
const updatedWebViewDefinition = { ...webViewDefinition };
// For each updatable property that is specified, overwrite the webViewDefinition's property
// If update properties aren't specified, keep the original values
WEBVIEW_DEFINITION_UPDATABLE_PROPERTY_KEYS.forEach((key) => {
if (key in updateInfo && updatedWebViewDefinition[key] !== updateInfo[key]) {
// Everything worked until I added multiple different types for the properties of
// WebViewDefinitionUpdateInfo. Now I guess TypeScript isn't smart enough to realize that the
// property is going to be the same between these two objects since they both have all the
// possible properties of the key with the same types and are using the same key. Too bad :/
// @ts-ignore ts(2322)
updatedWebViewDefinition[key] = updateInfo[key];
didUpdateAnyProperties = true;
}
});
return didUpdateAnyProperties ? updatedWebViewDefinition : undefined;
}
/**
* Clones and converts web view definition used in an actual docking tab into saveable web view
* information by stripping out the members we don't want to save. Does not modify the original web
* view definition.
*
* @param webViewDefinition Web view to save
* @returns Saveable web view information based on `webViewDefinition`
*/
export function convertWebViewDefinitionToSaved(
webViewDefinition: WebViewDefinition,
): SavedWebViewDefinition {
const webViewDefinitionCloned: Omit<WebViewDefinition, 'content'> &
Partial<Pick<WebViewDefinition, Exclude<SavedWebViewDefinitionOmittedKeys, 'styles'>>> &
Partial<Pick<WebViewDefinitionReact, 'styles'>> = { ...webViewDefinition };
SAVED_WEBVIEW_DEFINITION_OMITTED_KEYS.forEach((key) => {
delete webViewDefinitionCloned[key];
});
return webViewDefinitionCloned;
}
/** Explanation in web-view.service-model.ts */
async function getSavedWebViewDefinition(
webViewId: string,
): Promise<SavedWebViewDefinition | undefined> {
const webViewDefinition = (await getDockLayout()).getWebViewDefinition(webViewId);
if (webViewDefinition === undefined) return undefined;
return convertWebViewDefinitionToSaved(webViewDefinition);
}
/**
* Gets the saved properties on the WebView definition with the specified ID
*
* @param webViewId The ID of the WebView whose saved properties to get
* @returns Saved properties of the WebView definition with the specified ID or undefined if not
* found
* @throws If the papi dock layout has not been registered
*/
export function getSavedWebViewDefinitionSync(
webViewId: string,
): SavedWebViewDefinition | undefined {
const webViewDefinition = getDockLayoutSync().getWebViewDefinition(webViewId);
if (webViewDefinition === undefined) return undefined;
return convertWebViewDefinitionToSaved(webViewDefinition);
}
// #endregion
// #region Web view options
/** Set up defaults for options for getting a web view */
function getWebViewOptionsDefaults(options: GetWebViewOptions): GetWebViewOptions {
const optionsDefaulted = cloneDeep(options);
if ('existingId' in optionsDefaulted && !('createNewIfNotFound' in optionsDefaulted))
optionsDefaulted.createNewIfNotFound = true;
return optionsDefaulted;
}
// #endregion
// #region Set up global variables to use in `getWebView`'s `imports` below
globalThis.getSavedWebViewDefinitionById = getSavedWebViewDefinitionSync;
globalThis.updateWebViewDefinitionById = updateWebViewDefinitionSync;
// #endregion
// #region getWebView
/**
* Creates a new web view or gets an existing one depending on if you request an existing one and if
* the web view provider decides to give that existing one to you (it is up to the provider).
*
* @param webViewType Type of WebView to create
* @param layout Information about where you want the web view to go. Defaults to adding as a tab
* @param options Options that affect what this function does. For example, you can provide an
* existing web view ID to request an existing web view with that ID.
* @returns Promise that resolves to the ID of the webview we got or undefined if the provider did
* not create a WebView for this request.
* @throws If something went wrong like the provider for the webViewType was not found
*/
export const getWebView = async (
webViewType: WebViewType,
layout: Layout = { type: 'tab' },
options: GetWebViewOptions = {},
): Promise<WebViewId | undefined> => {
const optionsDefaulted = getWebViewOptionsDefaults(options);
// ENHANCEMENT: If they aren't looking for an existingId, we could get the webview without
// searching for an existing webview and send it to the renderer, skipping the part where we send
// to the renderer, then search for an existing webview, then get the webview
// Get the webview definition from the webview provider
const webViewProvider = await webViewProviderService.get(webViewType);
if (!webViewProvider)
throw new Error(`getWebView: Cannot find Web View Provider for webview type ${webViewType}`);
// Find existing webView if one exists
/** Either the existing webview with the specified ID or a placeholder webview if one was not found */
let existingSavedWebView: SavedWebViewDefinition | undefined;
// Look for existing webview
if (optionsDefaulted.existingId) {
// Expect this to be a tab.
// eslint-disable-next-line no-type-assertion/no-type-assertion
const existingWebView = (await getDockLayout()).dockLayout.find(
optionsDefaulted.existingId === '?'
? // If they provided '?', that means look for any webview with a matching webViewType
(item) => {
// This is not a webview
if (!('data' in item)) return false;
// Find any webview with the specified webViewType. Type assert the unknown `data`.
// eslint-disable-next-line no-type-assertion/no-type-assertion
return (item.data as WebViewDefinition).webViewType === webViewType;
}
: // If they provided any other string, look for a webview with that ID
optionsDefaulted.existingId,
) as TabInfo | undefined;
if (existingWebView) {
// We found the webview! Save it to send to the web view provider
existingSavedWebView = convertWebViewDefinitionToSaved(
// Type assert the unknown `data`.
// eslint-disable-next-line no-type-assertion/no-type-assertion
existingWebView.data as WebViewDefinition,
);
// Load the web view state since the web view provider doesn't have access to the data store
existingSavedWebView.state = getFullWebViewStateById(existingWebView.id);
}
}
// We didn't find an existing web view with the ID
if (!existingSavedWebView) {
// If we are not looking to create a new webview, then don't.
if ('existingId' in optionsDefaulted && !optionsDefaulted.createNewIfNotFound) return undefined;
// If we want to create a new webview, set a placeholder with a new ID
existingSavedWebView = { webViewType, id: newGuid() };
}
// Create the new webview or load if it already existed
const webView = await webViewProvider.getWebView(existingSavedWebView, optionsDefaulted);
// The web view provider didn't want to create this web view
if (!webView) return undefined;
// Set up WebViewDefinition default values
/** WebView.contentType is assumed to be React by default. Extensions can specify otherwise */
const contentType = webView.contentType ? webView.contentType : WebViewContentType.React;
/** Default allowScripts to false for WebViewContentType.URL and true otherwise */
let { allowScripts } = webView;
if (contentType !== WebViewContentType.URL) allowScripts = webView.allowScripts ?? true;
/** Default allowSameOrigin to true */
const allowSameOrigin = webView.allowSameOrigin ?? true;
/**
* Only allow connecting to `papi-extension:` and `https:` urls. For HTML and React WebViews, this
* controls the `frame-src` directive and therefore which urls can be iframe `src`es in the
* WebView. For URL WebViews, this controls what urls the WebView can be.
*/
let { allowedFrameSources } = webView;
if (contentType !== WebViewContentType.URL && allowedFrameSources)
allowedFrameSources = allowedFrameSources.filter(
(hostValue) => startsWith(hostValue, 'https:') || startsWith(hostValue, 'papi-extension:'),
);
// Validate the WebViewDefinition to make sure it is acceptable
// If this is a URL WebView, it must match at least one of its `allowedFrameSources` Regex strings
// if any are supplied
if (
contentType === WebViewContentType.URL &&
allowedFrameSources &&
!allowedFrameSources.some((regexString) => new RegExp(regexString).test(webView.content))
)
throw new Error(
`getWebView: URL WebView content ${webView.content} did not match any of its allowedFrameSources!`,
);
if (webView.state)
// The web view provider might have updated the web view state, so save it
setFullWebViewStateById(webView.id, webView.state);
// `webViewRequire`, `getWebViewStateById`, `setWebViewStateById` and `resetWebViewStateById` below are defined in `src\renderer\global-this.model.ts`
// `useWebViewState` below is defined in `src\shared\global-this.model.ts`
// We have to bind `useWebViewState` to the current `window` context because calls within PAPI don't have access to a webview's `window` context
/**
* String that sets up 'import' statements in the webview to pull in libraries and clear out
* internet access and such
*
* WARNING: `window.top` is not deletable as a security feature (websites need to know if they are
* running embedded in an iframe), so the child iframes are NOT isolated from their parents. We
* perform a number of tasks to mitigate this issue, but it would be very nice to find a way to
* properly delete `window.top`
*/
const imports = `
window.papi = window.parent.papi;
window.React = window.parent.React;
window.ReactJsxRuntime = window.parent.ReactJsxRuntime;
window.ReactDom = window.parent.ReactDom;
window.ReactDOMClient = window.parent.ReactDOMClient;
window.createRoot = window.parent.createRoot;
window.SillsdevScripture = window.parent.SillsdevScripture;
var require = window.parent.webViewRequire;
var getWebViewStateById = window.parent.getWebViewStateById;
var setWebViewStateById = window.parent.setWebViewStateById;
var resetWebViewStateById = window.parent.resetWebViewStateById;
window.getWebViewState = (stateKey, defaultValue) => { return getWebViewStateById('${webView.id}', stateKey, defaultValue) };
window.setWebViewState = (stateKey, stateValue) => { setWebViewStateById('${webView.id}', stateKey, stateValue) };
window.resetWebViewState = (stateKey) => { resetWebViewStateById('${webView.id}', stateKey) };
window.useWebViewState = window.parent.useWebViewState.bind(window);
window.useWebViewScrollGroupScrRef = window.parent.useWebViewScrollGroupScrRef.bind(window);
var getSavedWebViewDefinitionById = window.parent.getSavedWebViewDefinitionById;
window.getSavedWebViewDefinition = () => { return getSavedWebViewDefinitionById('${webView.id}')};
var updateWebViewDefinitionById = window.parent.updateWebViewDefinitionById;
window.updateWebViewDefinition = (webViewDefinitionUpdateInfo) => { return updateWebViewDefinitionById('${webView.id}', webViewDefinitionUpdateInfo)};
window.fetch = papi.fetch;
window.WebSocket = papi.WebSocket;
window.XMLHttpRequest = papi.XMLHttpRequest;
delete window.parent;
delete window.top;
delete window.frameElement;
`;
/** Nonce used to allow scripts and styles to run */
// TODO: Generating nonces every time causes webviews to rerender every time `getWebView` is used
// on an existing webview such as when the extension host is restarted. Should we save webview
// nonces so the `content` can be the same and not have to rerender?
// Or this could solve the problem as well https://github.com/paranext/paranext-core/issues/282
const srcNonce = newNonce();
// Build the contents of the iframe
let webViewContent: string;
/** CSP for allowing only certain scripts and styles */
let specificSrcPolicy: string;
switch (contentType) {
case WebViewContentType.HTML:
// Add wrapping to turn a plain string into an iframe
webViewContent = webView.content.includes('<html')
? webView.content
: `<html><head></head><body>${webView.content}</body></html>`;
// TODO: Please combine our CSP with HTML-provided CSP so we can add the import nonce and they can add nonces and stuff instead of allowing 'unsafe-inline'
specificSrcPolicy = "'unsafe-inline'";
break;
case WebViewContentType.URL:
webViewContent = webView.content;
// CSP does not apply to these webViews. If we ever add a `csp` attribute to WebView iframes,
// we might need to add this URL's schema to the CSP
specificSrcPolicy = '';
break;
default: {
// Defaults to React webview definition.
// eslint-disable-next-line no-type-assertion/no-type-assertion
const reactWebView = webView as WebViewDefinitionReact;
// Add the component as a script
// WARNING: DO NOT add anything between the closing of the script tag and the insertion of
// reactWebView.contents. Doing so would mess up debugging web views
webViewContent = `
<html>
<head>
${
reactWebView.styles
? `<style nonce="${srcNonce}">
${reactWebView.styles}
</style>`
: ''
}
</head>
<body>
<div id="root">
</div>
<script nonce="${srcNonce}">${reactWebView.content}
function initializeReact() {
const container = document.getElementById('root');
const root = createRoot(container);
function renderRoot(savedDefinition) {
// Set up WebViewProps to pass into the WebView component
const savedWebViewDefinition = savedDefinition ?? window.getSavedWebViewDefinition();
if (!savedWebViewDefinition)
throw new Error(
'renderRoot error! getSavedWebViewDefinition returned undefined for web view ${webView.id}! This is unexpected and will cause issues. Please investigate.'
);
const webViewProps = {
...savedWebViewDefinition,
useWebViewState: window.useWebViewState,
useWebViewScrollGroupScrRef: window.useWebViewScrollGroupScrRef,
updateWebViewDefinition: window.updateWebViewDefinition,
};
root.render(React.createElement(globalThis.webViewComponent, webViewProps));