Repository navigation
Expand file tree
/
Copy pathJavaScriptBridge.cs
More file actions
382 lines (338 loc) · 16.1 KB
/
Copy pathJavaScriptBridge.cs
File metadata and controls
382 lines (338 loc) · 16.1 KB
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
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Toolkit.Win32.UI.Controls.Interop.WinRT;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Microsoft.Toolkit.Win32.UI.Controls.WebViewExtensions
{
/// <summary>
/// A means of communicating bi-directionally with JavaScript on a web page.
/// </summary>
public class JavaScriptBridge
{
/// <summary>
/// Initializes a new instance of the <see cref="JavaScriptBridge"/> class.
/// </summary>
/// <param name="webView">A <see cref="IWebView"/> instance.</param>
/// <exception cref="ArgumentNullException">When <paramref name="webView"/> is null.</exception>
public JavaScriptBridge(IWebView webView)
{
WebView = webView ?? throw new ArgumentNullException(nameof(webView));
ScriptingDelegates = new Dictionary<string, Func<Dictionary<string, object>, object>>(StringComparer.OrdinalIgnoreCase);
AllowedScriptNotifyUris = new HashSet<Uri>();
// Ensure the following properties are set: We rely on JavaScript and ScriptNotify for the bridge to function
WebView.IsJavaScriptEnabled = true;
WebView.IsScriptNotifyAllowed = true;
IsActive = false;
}
/// <summary>
/// Gets or sets a safe list of Uniform Resource Identifiers (URIs) that are permitted to fire
/// <see cref="IWebView.ScriptNotify" /> events to the <see cref="IWebView" /> for consumption by the
/// <see cref="JavaScriptBridge" />
/// </summary>
public ICollection<Uri> AllowedScriptNotifyUris { get; }
/// <summary>
/// Gets a value indicating whether this instance is actively listening to <see cref="IWebView.ScriptNotify"/> events..
/// </summary>
/// <value><see langword="true" /> if this instance is active; otherwise, <see langword="false" />.</value>
public bool IsActive { get; private set; }
/// <summary>
/// Gets or sets the .NET delegates that may be invoked from JavaScript.
/// </summary>
/// <value>The scripting delegates.</value>
protected Dictionary<string, Func<Dictionary<string, object>, object>> ScriptingDelegates { get; }
/// <summary>
/// Gets the <see cref="IWebView"/> instance.
/// </summary>
/// <value>The web view.</value>
protected IWebView WebView { get; }
/// <summary>
/// Creates an instance of <see cref="JavaScriptBridge"/> and starts listening to <see cref="IWebView.ScriptNotify"/> messages.
/// </summary>
/// <param name="webView">A <see cref="IWebView"/> instance.</param>
/// <param name="allowedOrigins">The set of allowed origins for <see cref="IWebView.ScriptNotify"/> events.</param>
/// <returns>An instance of <see cref="JavaScriptBridge" />.</returns>
/// <exception cref="ArgumentNullException">
/// Occurs when <paramref name="webView"/> or <paramref name="allowedOrigins" /> is null.
/// </exception>
/// <exception cref="ArgumentOutOfRangeException">allowedOrigins - Must have at least one origin.</exception>
/// <seealso cref="Start"/>
/// <seealso cref="AllowedScriptNotifyUris"/>
public static JavaScriptBridge CreateAndStart(
IWebView webView,
params Uri[] allowedOrigins
)
{
if (allowedOrigins == null)
{
throw new ArgumentNullException(nameof(allowedOrigins));
}
if (!allowedOrigins.Any())
{
throw new ArgumentOutOfRangeException(nameof(allowedOrigins), "Must have at least one origin.");
}
var retval = new JavaScriptBridge(webView);
foreach (var origin in allowedOrigins)
{
retval.AllowedScriptNotifyUris.Add(origin);
}
retval.Start();
return retval;
}
/// <summary>
/// Adds the scripting delegate with a given <paramref name="handlerId"/>.
/// </summary>
/// <param name="handlerId">The identity of the delegate to be used by JavaScript.</param>
/// <param name="action">The action to be performed.</param>
/// <remarks>
/// The <paramref name="action"/> is a callback to be performed when invoking the delegate by id. The callback accepts a named parameter collection
/// via <see cref="Dictionary{String,Object}"/> and permits a return of <see cref="Object"/>. The callback itself need not accept any parameters, nor
/// need it return a value. If no parameters are passed, an empty <see cref="Dictionary{String,Object}"/> is passed for the parameters. If the callback
/// does not produce a return value, <see langword="null" /> is returned.
/// </remarks>
public void AddScriptingHandler(string handlerId, Func<Dictionary<string, object>, object> action)
{
ScriptingDelegates.Add(handlerId, action);
}
/// <summary>
/// When overridden in a class, provides custom logic to be executed after <see cref="Start"/>.
/// </summary>
/// <seealso cref="Start"/>.
/// <remarks>This implementation ensures the bridge is loaded by handling <see cref="IWebView.DOMContentLoaded"/> and the
/// <see cref="IWebView.Source"/> is a valid <see cref="AllowedScriptNotifyUris"/>.</remarks>
public virtual void OnStarted()
{
WebView.DOMContentLoaded += OnWebViewDOMContentLoaded;
}
/// <summary>
/// When overridden in a class, provides custom logic to be executed after <see cref="Stop"/>.
/// </summary>
/// <seealso cref="Stop"/>.
/// <remarks>Stops listening for <see cref="IWebView.DOMContentLoaded"/>.</remarks>
/// <seealso cref="OnStarted"/>
public virtual void OnStopped()
{
WebView.DOMContentLoaded -= OnWebViewDOMContentLoaded;
}
/// <summary>
/// Starts listening for <see cref="IWebView.ScriptNotify"/> events.
/// </summary>
/// <seealso cref="IWebView.ScriptNotify"/>
/// <seealso cref="AllowedScriptNotifyUris"/>
/// <seealso cref="IsActive"/>
/// <seealso cref="OnStarted"/>
public void Start()
{
if (!IsActive)
{
IsActive = true;
WebView.ScriptNotify += OnWebViewScriptNotify;
OnStarted();
}
}
/// <summary>
/// Stops listening for <see cref="IWebView.ScriptNotify"/> events.
/// </summary>
/// <seealso cref="IsActive"/>
/// <seealso cref="IWebView.ScriptNotify"/>
/// <seealso cref="OnStopped"/>
public void Stop()
{
if (IsActive)
{
IsActive = false;
WebView.ScriptNotify -= OnWebViewScriptNotify;
OnStopped();
}
}
/// <summary>
/// Handle the <see cref="IWebView.DOMContentLoaded" /> event, ensuring the JavaScript bridge is loaded.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The <see cref="WebViewControlDOMContentLoadedEventArgs"/> instance containing the event data.</param>
/// <remarks>
/// The bridge can be loaded by
/// - Explicitly using a script tag in the markup of the page, or
/// - Explicitly through handling the navigation or DOM events
///
/// If the JavaScript bridge is already loaded, it will not be loaded again.
/// </remarks>
/// <seealso cref="AllowedScriptNotifyUris"/>
/// <seealso cref="LoadBridge"/>
private void OnWebViewDOMContentLoaded(object sender, WebViewControlDOMContentLoadedEventArgs e)
{
if (e.Uri != null &&
AllowedScriptNotifyUris != null &&
AllowedScriptNotifyUris.Contains(e.Uri) &&
sender is IWebView webView)
{
LoadBridge();
}
}
/// <summary>
/// Loads the JavaScript bridge into a <paramref name="webView" /> instance if not already loaded.
/// </summary>
/// <param name="webView">A <see cref="IWebView" /> instance.</param>
/// <remarks>
/// Bridge is not loaded unless the navigation was successful.
/// </remarks>
public async Task LoadBridge()
{
Trace.WriteLine("Executing JavaScript to detect presence of JavaScriptBridge", Constants.TraceName);
var result = await WebView.InvokeScriptAsync("eval", "typeof JavaScriptBridge == 'object'");
if (!"true".Equals(result))
{
// NOT already loaded. Load from disk
Trace.WriteLine("Could not detect JavaScriptBridge. Injecting script", Constants.TraceName);
var contents = File.ReadAllText(Constants.BridgeJavaScript);
WebView.Eval(contents);
}
}
/// <summary>
/// Handles the <see cref="IWebView.ScriptNotify" /> event.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The <see cref="WebViewControlScriptNotifyEventArgs"/> instance containing the event data.</param>
/// <remarks>
/// Since there may be other uses of <code>window.external.notify</code> in use, this method checks to see if the
/// message came from a URI within <see cref="AllowedScriptNotifyUris"/> and the bridge before processing it. If it did,
/// it triggers processing of all queued messages within the bridge asynchronously.
/// </remarks>
/// <seealso cref="AllowedScriptNotifyUris"/>
/// <seealso cref="IsJavaScriptBridgeMessage"/>
/// <seealso cref="FlushAsync"/>
private void OnWebViewScriptNotify(object sender, WebViewControlScriptNotifyEventArgs e)
{
Trace.WriteLine($"Entering {typeof(JavaScriptBridge).FullName}.{nameof(OnWebViewScriptNotify)}", Constants.TraceName);
// White list
// WinForms/WPF do not validate the origins for ScriptNotify like UWP does
// This needs to be done manually for security
if (e.Uri != null && AllowedScriptNotifyUris != null && AllowedScriptNotifyUris.Contains(e.Uri))
{
var message = e.Value;
if (sender is IWebView webView && IsJavaScriptBridgeMessage(message))
{
Trace.WriteLine($"Received command {message}", Constants.TraceName);
// Asynchronously flush the messages from the JavaScript bridge
FlushAsync();
}
}
else
{
Trace.WriteLine($"{nameof(IWebView.ScriptNotify)} received from unknown origin {e.Uri?.Host}", Constants.TraceName);
}
}
/// <summary>
/// Determines if the <paramref name="message" /> is from the JavaScript bridge.
/// </summary>
/// <param name="message">The contents of <see cref="WebViewControlScriptNotifyEventArgs.Value" />.</param>
/// <returns><see langword="true" /> if message originated from JavaScript bridge; otherwise, <see langword="false" />.</returns>
private bool IsJavaScriptBridgeMessage(string message)
{
if (!string.IsNullOrEmpty(message))
{
// If using the helper methods "sendData" and "callNative" a message will fall into this
if (message.StartsWith("jsbridge", StringComparison.OrdinalIgnoreCase))
{
if (message.IndexOf("queue_message", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
// If pushing a message directly, it will be this
// Need to make sure in this case it is not malformed
JToken token = null;
try
{
token = JsonConvert.DeserializeObject<JToken>(message);
}
catch (Exception)
{
}
if (token?[nameof(JavaScriptBridgeMessage.Handler)] != null)
{
var delegateName = token[nameof(JavaScriptBridgeMessage.Handler)].Value<string>();
if (!string.IsNullOrEmpty(delegateName))
{
return true;
}
}
}
return false;
}
/// <summary>
/// Flushes all queued messages from the JavaScript bridge
/// </summary>
/// <returns>A <see cref="Task"/>.</returns>
/// <exception cref="NotSupportedException">If the requested callback identifier is not in the list of <see cref="ScriptingDelegates"/>.</exception>
/// <seealso cref="ScriptingDelegates"/>
/// <remarks>
/// If an exception is encountered while processing the handler it is captured and returned to the caller via <see cref="JavaScriptBridgeMessage.ErrorData"/>.
/// Each message is handled independently and dispatched back to the bridge in the order in which they are received.
/// </remarks>
private async Task FlushAsync()
{
{
Trace.WriteLine($"Calling {Constants.BridgeJavaScript} method JavaScriptBridge.fetchQueue()",
Constants.TraceName);
List<JavaScriptBridgeMessage> messages;
try
{
messages = await WebView.InvokeScriptFunctionAsync<List<JavaScriptBridgeMessage>>(
"JavaScriptBridge",
"fetchQueue");
}
catch (Exception)
{
// TODO: Catch a more specific exception here
throw;
}
if (messages != null)
{
Trace.WriteLine($"Retrieved {messages.Count} message{(messages.Count != 1 ? "s" : string.Empty)}",
Constants.TraceName);
foreach (var message in messages)
{
try
{
var handlerName = message.Handler;
if (ScriptingDelegates.ContainsKey(handlerName))
{
message.ResponseData = ScriptingDelegates[handlerName](message.HandlerData);
}
else
{
throw new NotSupportedException($"Handler '{handlerName}' not supported.");
}
}
catch (Exception exception)
{
Trace.WriteLine(
$"Error while processing message: [{exception.GetType()}] {exception.Message}",
Constants.TraceName);
// Encountered an excpetion while processing the message
// Mask any potential disclosure issues by wrapping the exception as our own
var e =
new JavaScriptBridgeException(exception.Message)
{
Source = Constants.TraceName
};
message.ErrorData = e;
}
finally
{
if (!string.IsNullOrEmpty(message.CallbackId))
{
WebView.DispatchMessage(message);
}
}
}
}
}
}
}
}