|
| 1 | +/* |
| 2 | + * |
| 3 | + * Licensed to the Apache Software Foundation (ASF) under one |
| 4 | + * or more contributor license agreements. See the NOTICE file |
| 5 | + * distributed with this work for additional information |
| 6 | + * regarding copyright ownership. The ASF licenses this file |
| 7 | + * to you under the Apache License, Version 2.0 (the |
| 8 | + * "License"); you may not use this file except in compliance |
| 9 | + * with the License. You may obtain a copy of the License at |
| 10 | + * |
| 11 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 12 | + * |
| 13 | + * Unless required by applicable law or agreed to in writing, |
| 14 | + * software distributed under the License is distributed on an |
| 15 | + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 16 | + * KIND, either express or implied. See the License for the |
| 17 | + * specific language governing permissions and limitations |
| 18 | + * under the License. |
| 19 | + * |
| 20 | +*/ |
| 21 | + |
| 22 | +/** |
| 23 | + * Creates a gap bridge iframe used to notify the native code about queued |
| 24 | + * commands. |
| 25 | + */ |
| 26 | +var cordova = require('cordova'), |
| 27 | + channel = require('cordova/channel'), |
| 28 | + utils = require('cordova/utils'), |
| 29 | + base64 = require('cordova/base64'), |
| 30 | + // XHR mode does not work on iOS 4.2. |
| 31 | + // XHR mode's main advantage is working around a bug in -webkit-scroll, which |
| 32 | + // doesn't exist only on iOS 5.x devices. |
| 33 | + // IFRAME_NAV is the fastest. |
| 34 | + // IFRAME_HASH could be made to enable synchronous bridge calls if we wanted this feature. |
| 35 | + jsToNativeModes = { |
| 36 | + IFRAME_NAV: 0, // Default. Uses a new iframe for each poke. |
| 37 | + // XHR bridge appears to be flaky sometimes: CB-3900, CB-3359, CB-5457, CB-4970, CB-4998, CB-5134 |
| 38 | + XHR_NO_PAYLOAD: 1, // About the same speed as IFRAME_NAV. Performance not about the same as IFRAME_NAV, but more variable. |
| 39 | + XHR_WITH_PAYLOAD: 2, // Flakey, and not as performant |
| 40 | + XHR_OPTIONAL_PAYLOAD: 3, // Flakey, and not as performant |
| 41 | + IFRAME_HASH_NO_PAYLOAD: 4, // Not fully baked. A bit faster than IFRAME_NAV, but risks jank since poke happens synchronously. |
| 42 | + IFRAME_HASH_WITH_PAYLOAD: 5, // Slower than no payload. Maybe since it has to be URI encoded / decoded. |
| 43 | + WK_WEBVIEW_BINDING: 6 // Only way that works for WKWebView :) |
| 44 | + }, |
| 45 | + bridgeMode, |
| 46 | + execIframe, |
| 47 | + execHashIframe, |
| 48 | + hashToggle = 1, |
| 49 | + execXhr, |
| 50 | + requestCount = 0, |
| 51 | + vcHeaderValue = null, |
| 52 | + commandQueue = [], // Contains pending JS->Native messages. |
| 53 | + isInContextOfEvalJs = 0, |
| 54 | + failSafeTimerId = 0; |
| 55 | + |
| 56 | +function shouldBundleCommandJson() { |
| 57 | + if (bridgeMode === jsToNativeModes.XHR_WITH_PAYLOAD) { |
| 58 | + return true; |
| 59 | + } |
| 60 | + if (bridgeMode === jsToNativeModes.XHR_OPTIONAL_PAYLOAD) { |
| 61 | + var payloadLength = 0; |
| 62 | + for (var i = 0; i < commandQueue.length; ++i) { |
| 63 | + payloadLength += commandQueue[i].length; |
| 64 | + } |
| 65 | + // The value here was determined using the benchmark within CordovaLibApp on an iPad 3. |
| 66 | + return payloadLength < 4500; |
| 67 | + } |
| 68 | + return false; |
| 69 | +} |
| 70 | + |
| 71 | +function massageArgsJsToNative(args) { |
| 72 | + if (!args || utils.typeName(args) != 'Array') { |
| 73 | + return args; |
| 74 | + } |
| 75 | + var ret = []; |
| 76 | + args.forEach(function(arg, i) { |
| 77 | + if (utils.typeName(arg) == 'ArrayBuffer') { |
| 78 | + ret.push({ |
| 79 | + 'CDVType': 'ArrayBuffer', |
| 80 | + 'data': base64.fromArrayBuffer(arg) |
| 81 | + }); |
| 82 | + } else { |
| 83 | + ret.push(arg); |
| 84 | + } |
| 85 | + }); |
| 86 | + return ret; |
| 87 | +} |
| 88 | + |
| 89 | +function massageMessageNativeToJs(message) { |
| 90 | + if (message.CDVType == 'ArrayBuffer') { |
| 91 | + var stringToArrayBuffer = function(str) { |
| 92 | + var ret = new Uint8Array(str.length); |
| 93 | + for (var i = 0; i < str.length; i++) { |
| 94 | + ret[i] = str.charCodeAt(i); |
| 95 | + } |
| 96 | + return ret.buffer; |
| 97 | + }; |
| 98 | + var base64ToArrayBuffer = function(b64) { |
| 99 | + return stringToArrayBuffer(atob(b64)); |
| 100 | + }; |
| 101 | + message = base64ToArrayBuffer(message.data); |
| 102 | + } |
| 103 | + return message; |
| 104 | +} |
| 105 | + |
| 106 | +function convertMessageToArgsNativeToJs(message) { |
| 107 | + var args = []; |
| 108 | + if (!message || !message.hasOwnProperty('CDVType')) { |
| 109 | + args.push(message); |
| 110 | + } else if (message.CDVType == 'MultiPart') { |
| 111 | + message.messages.forEach(function(e) { |
| 112 | + args.push(massageMessageNativeToJs(e)); |
| 113 | + }); |
| 114 | + } else { |
| 115 | + args.push(massageMessageNativeToJs(message)); |
| 116 | + } |
| 117 | + return args; |
| 118 | +} |
| 119 | + |
| 120 | +function iOSExec() { |
| 121 | + if (bridgeMode === undefined) { |
| 122 | + bridgeMode = jsToNativeModes.IFRAME_NAV; |
| 123 | + } |
| 124 | + |
| 125 | + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.cordova && window.webkit.messageHandlers.cordova.postMessage) { |
| 126 | + bridgeMode = jsToNativeModes.WK_WEBVIEW_BINDING; |
| 127 | + } |
| 128 | + |
| 129 | + var successCallback, failCallback, service, action, actionArgs, splitCommand; |
| 130 | + var callbackId = null; |
| 131 | + if (typeof arguments[0] !== "string") { |
| 132 | + // FORMAT ONE |
| 133 | + successCallback = arguments[0]; |
| 134 | + failCallback = arguments[1]; |
| 135 | + service = arguments[2]; |
| 136 | + action = arguments[3]; |
| 137 | + actionArgs = arguments[4]; |
| 138 | + |
| 139 | + // Since we need to maintain backwards compatibility, we have to pass |
| 140 | + // an invalid callbackId even if no callback was provided since plugins |
| 141 | + // will be expecting it. The Cordova.exec() implementation allocates |
| 142 | + // an invalid callbackId and passes it even if no callbacks were given. |
| 143 | + callbackId = 'INVALID'; |
| 144 | + } else { |
| 145 | + // FORMAT TWO, REMOVED |
| 146 | + try { |
| 147 | + splitCommand = arguments[0].split("."); |
| 148 | + action = splitCommand.pop(); |
| 149 | + service = splitCommand.join("."); |
| 150 | + actionArgs = Array.prototype.splice.call(arguments, 1); |
| 151 | + |
| 152 | + console.log('The old format of this exec call has been removed (deprecated since 2.1). Change to: ' + |
| 153 | + "cordova.exec(null, null, \"" + service + "\", \"" + action + "\"," + JSON.stringify(actionArgs) + ");" |
| 154 | + ); |
| 155 | + return; |
| 156 | + } catch (e) {} |
| 157 | + } |
| 158 | + |
| 159 | + // If actionArgs is not provided, default to an empty array |
| 160 | + actionArgs = actionArgs || []; |
| 161 | + |
| 162 | + // Register the callbacks and add the callbackId to the positional |
| 163 | + // arguments if given. |
| 164 | + if (successCallback || failCallback) { |
| 165 | + callbackId = service + cordova.callbackId++; |
| 166 | + cordova.callbacks[callbackId] = |
| 167 | + {success:successCallback, fail:failCallback}; |
| 168 | + } |
| 169 | + |
| 170 | + actionArgs = massageArgsJsToNative(actionArgs); |
| 171 | + |
| 172 | + var command = [callbackId, service, action, actionArgs]; |
| 173 | + |
| 174 | + // Stringify and queue the command. We stringify to command now to |
| 175 | + // effectively clone the command arguments in case they are mutated before |
| 176 | + // the command is executed. |
| 177 | + commandQueue.push(JSON.stringify(command)); |
| 178 | + |
| 179 | + if (bridgeMode === jsToNativeModes.WK_WEBVIEW_BINDING) { |
| 180 | + window.webkit.messageHandlers.cordova.postMessage(command); |
| 181 | + } else { |
| 182 | + // If we're in the context of a stringByEvaluatingJavaScriptFromString call, |
| 183 | + // then the queue will be flushed when it returns; no need for a poke. |
| 184 | + // Also, if there is already a command in the queue, then we've already |
| 185 | + // poked the native side, so there is no reason to do so again. |
| 186 | + if (!isInContextOfEvalJs && commandQueue.length == 1) { |
| 187 | + pokeNative(); |
| 188 | + } |
| 189 | + } |
| 190 | +} |
| 191 | + |
| 192 | +function pokeNative() { |
| 193 | + switch (bridgeMode) { |
| 194 | + case jsToNativeModes.XHR_NO_PAYLOAD: |
| 195 | + case jsToNativeModes.XHR_WITH_PAYLOAD: |
| 196 | + case jsToNativeModes.XHR_OPTIONAL_PAYLOAD: |
| 197 | + pokeNativeViaXhr(); |
| 198 | + break; |
| 199 | + default: // iframe-based. |
| 200 | + pokeNativeViaIframe(); |
| 201 | + } |
| 202 | +} |
| 203 | + |
| 204 | +function pokeNativeViaXhr() { |
| 205 | + // This prevents sending an XHR when there is already one being sent. |
| 206 | + // This should happen only in rare circumstances (refer to unit tests). |
| 207 | + if (execXhr && execXhr.readyState != 4) { |
| 208 | + execXhr = null; |
| 209 | + } |
| 210 | + // Re-using the XHR improves exec() performance by about 10%. |
| 211 | + execXhr = execXhr || new XMLHttpRequest(); |
| 212 | + // Changing this to a GET will make the XHR reach the URIProtocol on 4.2. |
| 213 | + // For some reason it still doesn't work though... |
| 214 | + // Add a timestamp to the query param to prevent caching. |
| 215 | + execXhr.open('HEAD', "/!gap_exec?" + (+new Date()), true); |
| 216 | + if (!vcHeaderValue) { |
| 217 | + vcHeaderValue = /.*\((.*)\)$/.exec(navigator.userAgent)[1]; |
| 218 | + } |
| 219 | + execXhr.setRequestHeader('vc', vcHeaderValue); |
| 220 | + execXhr.setRequestHeader('rc', ++requestCount); |
| 221 | + if (shouldBundleCommandJson()) { |
| 222 | + execXhr.setRequestHeader('cmds', iOSExec.nativeFetchMessages()); |
| 223 | + } |
| 224 | + execXhr.send(null); |
| 225 | +} |
| 226 | + |
| 227 | +function pokeNativeViaIframe() { |
| 228 | + // CB-5488 - Don't attempt to create iframe before document.body is available. |
| 229 | + if (!document.body) { |
| 230 | + setTimeout(pokeNativeViaIframe); |
| 231 | + return; |
| 232 | + } |
| 233 | + if (bridgeMode === jsToNativeModes.IFRAME_HASH_NO_PAYLOAD || bridgeMode === jsToNativeModes.IFRAME_HASH_WITH_PAYLOAD) { |
| 234 | + // TODO: This bridge mode doesn't properly support being removed from the DOM (CB-7735) |
| 235 | + if (!execHashIframe) { |
| 236 | + execHashIframe = document.createElement('iframe'); |
| 237 | + execHashIframe.style.display = 'none'; |
| 238 | + document.body.appendChild(execHashIframe); |
| 239 | + // Hash changes don't work on about:blank, so switch it to file:///. |
| 240 | + execHashIframe.contentWindow.history.replaceState(null, null, 'file:///#'); |
| 241 | + } |
| 242 | + // The delegate method is called only when the hash changes, so toggle it back and forth. |
| 243 | + hashToggle = hashToggle ^ 3; |
| 244 | + var hashValue = '%0' + hashToggle; |
| 245 | + if (bridgeMode === jsToNativeModes.IFRAME_HASH_WITH_PAYLOAD) { |
| 246 | + hashValue += iOSExec.nativeFetchMessages(); |
| 247 | + } |
| 248 | + execHashIframe.contentWindow.location.hash = hashValue; |
| 249 | + } else { |
| 250 | + // Check if they've removed it from the DOM, and put it back if so. |
| 251 | + if (execIframe && execIframe.contentWindow) { |
| 252 | + execIframe.contentWindow.location = 'gap://ready'; |
| 253 | + } else { |
| 254 | + execIframe = document.createElement('iframe'); |
| 255 | + execIframe.style.display = 'none'; |
| 256 | + execIframe.src = 'gap://ready'; |
| 257 | + document.body.appendChild(execIframe); |
| 258 | + } |
| 259 | + // Use a timer to protect against iframe being unloaded during the poke (CB-7735). |
| 260 | + // This makes the bridge ~ 7% slower, but works around the poke getting lost |
| 261 | + // when the iframe is removed from the DOM. |
| 262 | + // An onunload listener could be used in the case where the iframe has just been |
| 263 | + // created, but since unload events fire only once, it doesn't work in the normal |
| 264 | + // case of iframe reuse (where unload will have already fired due to the attempted |
| 265 | + // navigation of the page). |
| 266 | + failSafeTimerId = setTimeout(function() { |
| 267 | + if (commandQueue.length) { |
| 268 | + pokeNative(); |
| 269 | + } |
| 270 | + }, 50); // Making this > 0 improves performance (marginally) in the normal case (where it doesn't fire). |
| 271 | + } |
| 272 | +} |
| 273 | + |
| 274 | +iOSExec.jsToNativeModes = jsToNativeModes; |
| 275 | + |
| 276 | +iOSExec.setJsToNativeBridgeMode = function(mode) { |
| 277 | + // Remove the iFrame since it may be no longer required, and its existence |
| 278 | + // can trigger browser bugs. |
| 279 | + // https://issues.apache.org/jira/browse/CB-593 |
| 280 | + if (execIframe) { |
| 281 | + if (execIframe.parentNode) { |
| 282 | + execIframe.parentNode.removeChild(execIframe); |
| 283 | + } |
| 284 | + execIframe = null; |
| 285 | + } |
| 286 | + bridgeMode = mode; |
| 287 | +}; |
| 288 | + |
| 289 | +iOSExec.nativeFetchMessages = function() { |
| 290 | + // Stop listing for window detatch once native side confirms poke. |
| 291 | + if (failSafeTimerId) { |
| 292 | + clearTimeout(failSafeTimerId); |
| 293 | + failSafeTimerId = 0; |
| 294 | + } |
| 295 | + // Each entry in commandQueue is a JSON string already. |
| 296 | + if (!commandQueue.length) { |
| 297 | + return ''; |
| 298 | + } |
| 299 | + var json = '[' + commandQueue.join(',') + ']'; |
| 300 | + commandQueue.length = 0; |
| 301 | + return json; |
| 302 | +}; |
| 303 | + |
| 304 | +iOSExec.nativeCallback = function(callbackId, status, message, keepCallback) { |
| 305 | + return iOSExec.nativeEvalAndFetch(function() { |
| 306 | + var success = status === 0 || status === 1; |
| 307 | + var args = convertMessageToArgsNativeToJs(message); |
| 308 | + cordova.callbackFromNative(callbackId, success, status, args, keepCallback); |
| 309 | + }); |
| 310 | +}; |
| 311 | + |
| 312 | +iOSExec.nativeEvalAndFetch = function(func) { |
| 313 | + // This shouldn't be nested, but better to be safe. |
| 314 | + isInContextOfEvalJs++; |
| 315 | + try { |
| 316 | + func(); |
| 317 | + return iOSExec.nativeFetchMessages(); |
| 318 | + } finally { |
| 319 | + isInContextOfEvalJs--; |
| 320 | + } |
| 321 | +}; |
| 322 | + |
| 323 | +module.exports = iOSExec; |
0 commit comments