From b81dd569e3998019acee37cf4c31a832b756c2f6 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Tue, 30 Aug 2022 08:04:25 -0700 Subject: [PATCH 01/15] feat: capacitor core http initial implementation --- .../src/main/assets/native-bridge.js | 159 +++++++ .../main/java/com/getcapacitor/Bridge.java | 1 + .../main/java/com/getcapacitor/JSValue.java | 65 +++ .../getcapacitor/plugin/CapacitorHttp.java | 75 ++++ .../util/CapacitorHttpUrlConnection.java | 384 +++++++++++++++++ .../plugin/util/HttpRequestHandler.java | 405 ++++++++++++++++++ .../util/ICapacitorHttpUrlConnection.java | 15 + .../getcapacitor/plugin/util/MimeType.java | 17 + core/native-bridge.ts | 172 ++++++++ core/src/core-plugins.ts | 275 ++++++++++++ core/src/definitions-internal.ts | 2 + core/src/index.ts | 12 +- .../Capacitor/Plugins/CapacitorHttp.swift | 41 ++ .../Plugins/CapacitorUrlRequest.swift | 151 +++++++ .../Capacitor/Plugins/DefaultPlugins.m | 9 + .../Plugins/HttpRequestHandler.swift | 173 ++++++++ .../Capacitor/assets/native-bridge.js | 159 +++++++ 17 files changed, 2113 insertions(+), 2 deletions(-) create mode 100644 android/capacitor/src/main/java/com/getcapacitor/JSValue.java create mode 100644 android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java create mode 100644 android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java create mode 100644 android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java create mode 100644 android/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java create mode 100644 android/capacitor/src/main/java/com/getcapacitor/plugin/util/MimeType.java create mode 100644 ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift create mode 100644 ios/Capacitor/Capacitor/Plugins/CapacitorUrlRequest.swift create mode 100644 ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift diff --git a/android/capacitor/src/main/assets/native-bridge.js b/android/capacitor/src/main/assets/native-bridge.js index 30e97b22c..e4ab6ef06 100644 --- a/android/capacitor/src/main/assets/native-bridge.js +++ b/android/capacitor/src/main/assets/native-bridge.js @@ -241,6 +241,165 @@ const nativeBridge = (function (exports) { } return String(msg); }; + const platform = getPlatformId(win); + // TODO: Check cap config for opt-out + // patch fetch / XHR on Android/iOS + if (platform == 'android' || platform == 'ios') { + // store original fetch & XHR functions + win.CapacitorWebFetch = window.fetch; + win.CapacitorWebXMLHttpRequest = { + abort: window.XMLHttpRequest.prototype.abort, + open: window.XMLHttpRequest.prototype.open, + send: window.XMLHttpRequest.prototype.send, + setRequestHeader: window.XMLHttpRequest.prototype.setRequestHeader, + }; + // fetch patch + window.fetch = async (resource, options) => { + try { + // intercept request & pass to the bridge + const nativeResponse = await cap.nativePromise('CapacitorHttp', 'request', { + url: resource, + method: (options === null || options === void 0 ? void 0 : options.method) ? options.method : undefined, + data: (options === null || options === void 0 ? void 0 : options.body) ? options.body : undefined, + headers: (options === null || options === void 0 ? void 0 : options.headers) ? options.headers : undefined, + }); + // intercept & parse response before returning + const response = new Response(JSON.stringify(nativeResponse.data), { + headers: nativeResponse.headers, + status: nativeResponse.status, + }); + return response; + } + catch (error) { + return Promise.reject(error); + } + }; + // XHR event listeners + const addEventListeners = function () { + this.addEventListener('abort', function () { + if (typeof this.onabort === 'function') + this.onabort(); + }); + this.addEventListener('error', function () { + if (typeof this.onerror === 'function') + this.onerror(); + }); + this.addEventListener('load', function () { + if (typeof this.onload === 'function') + this.onload(); + }); + this.addEventListener('loadend', function () { + if (typeof this.onloadend === 'function') + this.onloadend(); + }); + this.addEventListener('loadstart', function () { + if (typeof this.onloadstart === 'function') + this.onloadstart(); + }); + this.addEventListener('readystatechange', function () { + if (typeof this.onreadystatechange === 'function') + this.onreadystatechange(); + }); + this.addEventListener('timeout', function () { + if (typeof this.ontimeout === 'function') + this.ontimeout(); + }); + }; + // XHR patch abort + window.XMLHttpRequest.prototype.abort = function () { + Object.defineProperties(this, { + _headers: { + value: {}, + writable: true, + }, + readyState: { + get: function () { + var _a; + return (_a = this._readyState) !== null && _a !== void 0 ? _a : 0; + }, + set: function (val) { + this._readyState = val; + this.dispatchEvent(new Event('readystatechange')); + } + }, + response: { + value: '', + writable: true, + }, + responseText: { + value: '', + writable: true, + }, + responseURL: { + value: '', + writable: true, + }, + status: { + value: 0, + writable: true, + }, + }); + this.readyState = 0; + this.dispatchEvent(new Event('abort')); + this.dispatchEvent(new Event('loadend')); + }; + // XHR patch open + window.XMLHttpRequest.prototype.open = function (method, url) { + this.abort(); + addEventListeners.call(this); + this._method = method; + this._url = url; + this.readyState = 1; + }; + // XHR patch set request header + window.XMLHttpRequest.prototype.setRequestHeader = function (header, value) { + this._headers[header] = value; + }; + // XHR patch send + window.XMLHttpRequest.prototype.send = function (body) { + try { + this.readyState = 2; + // intercept request & pass to the bridge + cap.nativePromise('CapacitorHttp', 'request', { + url: this._url, + method: this._method, + data: (body !== null) ? body : undefined, + headers: this._headers, + }).then((nativeResponse) => { + // intercept & parse response before returning + if (this.readyState == 2) { + this.dispatchEvent(new Event('loadstart')); + this.status = nativeResponse.status; + this.response = nativeResponse.data; + this.responseText = JSON.stringify(nativeResponse.data); + this.responseURL = nativeResponse.url; + this.readyState = 4; + this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('loadend')); + } + }).catch((error) => { + this.dispatchEvent(new Event('loadstart')); + this.status = error.status; + this.response = error.data; + this.responseText = JSON.stringify(error.data); + this.responseURL = error.url; + this.readyState = 4; + this.dispatchEvent(new Event('error')); + this.dispatchEvent(new Event('loadend')); + }); + } + catch (error) { + this.dispatchEvent(new Event('loadstart')); + this.status = 500; + this.response = error; + this.responseText = error.toString(); + this.responseURL = this._url; + this.readyState = 4; + this.dispatchEvent(new Event('error')); + this.dispatchEvent(new Event('loadend')); + } + }; + } // patch window.console on iOS and store original console fns const isIos = getPlatformId(win) === 'ios'; if (win.console && isIos) { diff --git a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java index 9c0ac5661..850536886 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java +++ b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java @@ -557,6 +557,7 @@ private void initWebView() { */ private void registerAllPlugins() { this.registerPlugin(com.getcapacitor.plugin.WebView.class); + this.registerPlugin(com.getcapacitor.plugin.CapacitorHttp.class); for (Class pluginClass : this.initialPlugins) { this.registerPlugin(pluginClass); diff --git a/android/capacitor/src/main/java/com/getcapacitor/JSValue.java b/android/capacitor/src/main/java/com/getcapacitor/JSValue.java new file mode 100644 index 000000000..84d0bb8bf --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/JSValue.java @@ -0,0 +1,65 @@ +package com.getcapacitor; + +import org.json.JSONException; + +/** + * Represents a single user-data value of any type on the capacitor PluginCall object. + */ +public class JSValue { + + private final Object value; + + /** + * @param call The capacitor plugin call, used for accessing the value safely. + * @param name The name of the property to access. + */ + public JSValue(PluginCall call, String name) { + this.value = this.toValue(call, name); + } + + /** + * Returns the coerced but uncasted underlying value. + */ + public Object getValue() { + return this.value; + } + + @Override + public String toString() { + return this.getValue().toString(); + } + + /** + * Returns the underlying value as a JSObject, or throwing if it cannot. + * + * @throws JSONException If the underlying value is not a JSObject. + */ + public JSObject toJSObject() throws JSONException { + if (this.value instanceof JSObject) return (JSObject) this.value; + throw new JSONException("JSValue could not be coerced to JSObject."); + } + + /** + * Returns the underlying value as a JSArray, or throwing if it cannot. + * + * @throws JSONException If the underlying value is not a JSArray. + */ + public JSArray toJSArray() throws JSONException { + if (this.value instanceof JSArray) return (JSArray) this.value; + throw new JSONException("JSValue could not be coerced to JSArray."); + } + + /** + * Returns the underlying value this object represents, coercing it into a capacitor-friendly object if supported. + */ + private Object toValue(PluginCall call, String name) { + Object value = null; + value = call.getArray(name, null); + if (value != null) return value; + value = call.getObject(name, null); + if (value != null) return value; + value = call.getString(name, null); + if (value != null) return value; + return call.getData().opt(name); + } +} \ No newline at end of file diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java new file mode 100644 index 000000000..1594510c7 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java @@ -0,0 +1,75 @@ +package com.getcapacitor.plugin; + +import android.Manifest; +import android.webkit.JavascriptInterface; + +import com.getcapacitor.CapConfig; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginConfig; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.annotation.Permission; +import com.getcapacitor.plugin.util.HttpRequestHandler; + +@CapacitorPlugin( + permissions = { + @Permission(strings = { Manifest.permission.WRITE_EXTERNAL_STORAGE }, alias = "HttpWrite"), + @Permission(strings = { Manifest.permission.READ_EXTERNAL_STORAGE }, alias = "HttpRead") + } +) +public class CapacitorHttp extends Plugin { + private void http(final PluginCall call, final String httpMethod) { + Runnable asyncHttpCall = new Runnable() { + @Override + public void run() { + try { + JSObject response = HttpRequestHandler.request(call, httpMethod); + call.resolve(response); + } catch (Exception e) { + System.out.println(e.toString()); + call.reject(e.getClass().getSimpleName(), e); + } + } + }; + Thread httpThread = new Thread(asyncHttpCall); + httpThread.start(); + } + + @JavascriptInterface + public boolean isDisabled() { + PluginConfig pluginConfig = getBridge().getConfig().getPluginConfiguration("CapacitorHttp"); + return pluginConfig.getBoolean("disabled", false); + } + + @PluginMethod + public void request(final PluginCall call) { + this.http(call, null); + } + + @PluginMethod + public void get(final PluginCall call) { + this.http(call, "GET"); + } + + @PluginMethod + public void post(final PluginCall call) { + this.http(call, "POST"); + } + + @PluginMethod + public void put(final PluginCall call) { + this.http(call, "PUT"); + } + + @PluginMethod + public void patch(final PluginCall call) { + this.http(call, "PATCH"); + } + + @PluginMethod + public void delete(final PluginCall call) { + this.http(call, "DELETE"); + } +} diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java new file mode 100644 index 000000000..29c377636 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java @@ -0,0 +1,384 @@ +package com.getcapacitor.plugin.util; + +import android.os.Build; +import android.os.LocaleList; +import android.text.TextUtils; + +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.JSValue; +import com.getcapacitor.PluginCall; + +import org.json.JSONException; + +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.URLEncoder; +import java.net.UnknownServiceException; +import java.nio.charset.StandardCharsets; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class CapacitorHttpUrlConnection implements ICapacitorHttpUrlConnection { + + private final HttpURLConnection connection; + + /** + * Make a new CapacitorHttpUrlConnection instance, which wraps around HttpUrlConnection + * and provides some helper functions for setting request headers and the request body + * @param conn the base HttpUrlConnection. You can pass the value from + * {@code (HttpUrlConnection) URL.openConnection()} + */ + public CapacitorHttpUrlConnection(HttpURLConnection conn) { + connection = conn; + this.setDefaultRequestProperties(); + } + + /** + * Returns the underlying HttpUrlConnection value + * @return the underlying HttpUrlConnection value + */ + public HttpURLConnection getHttpConnection() { + return connection; + } + + /** + * Set the value of the {@code allowUserInteraction} field of + * this {@code URLConnection}. + * + * @param isAllowedInteraction the new value. + * @throws IllegalStateException if already connected + */ + public void setAllowUserInteraction(boolean isAllowedInteraction) { + connection.setAllowUserInteraction(isAllowedInteraction); + } + + /** + * Set the method for the URL request, one of: + * are legal, subject to protocol restrictions. The default + * method is GET. + * + * @param method the HTTP method + * @exception ProtocolException if the method cannot be reset or if + * the requested method isn't valid for HTTP. + * @exception SecurityException if a security manager is set and the + * method is "TRACE", but the "allowHttpTrace" + * NetPermission is not granted. + */ + public void setRequestMethod(String method) throws ProtocolException { + connection.setRequestMethod(method); + } + + /** + * Sets a specified timeout value, in milliseconds, to be used + * when opening a communications link to the resource referenced + * by this URLConnection. If the timeout expires before the + * connection can be established, a + * java.net.SocketTimeoutException is raised. A timeout of zero is + * interpreted as an infinite timeout. + * + *

Warning: If the hostname resolves to multiple IP + * addresses, Android's default implementation of {@link HttpURLConnection} + * will try each in + * RFC 3484 order. If + * connecting to each of these addresses fails, multiple timeouts will + * elapse before the connect attempt throws an exception. Host names + * that support both IPv6 and IPv4 always have at least 2 IP addresses. + * + * @param timeout an {@code int} that specifies the connect + * timeout value in milliseconds + * @throws IllegalArgumentException if the timeout parameter is negative + */ + public void setConnectTimeout(int timeout) { + if (timeout < 0) { + throw new IllegalArgumentException("timeout can not be negative"); + } + connection.setConnectTimeout(timeout); + } + + /** + * Sets the read timeout to a specified timeout, in + * milliseconds. A non-zero value specifies the timeout when + * reading from Input stream when a connection is established to a + * resource. If the timeout expires before there is data available + * for read, a java.net.SocketTimeoutException is raised. A + * timeout of zero is interpreted as an infinite timeout. + * + * @param timeout an {@code int} that specifies the timeout + * value to be used in milliseconds + * @throws IllegalArgumentException if the timeout parameter is negative + */ + public void setReadTimeout(int timeout) { + if (timeout < 0) { + throw new IllegalArgumentException("timeout can not be negative"); + } + connection.setReadTimeout(timeout); + } + + /** + * Sets whether automatic HTTP redirects should be disabled + * @param disableRedirects the flag to determine if redirects should be followed + */ + public void setDisableRedirects(boolean disableRedirects) { + connection.setInstanceFollowRedirects(!disableRedirects); + } + + /** + * Sets the request headers given a JSObject of key-value pairs + * @param headers the JSObject values to map to the HttpUrlConnection request headers + */ + public void setRequestHeaders(JSObject headers) { + Iterator keys = headers.keys(); + while (keys.hasNext()) { + String key = keys.next(); + String value = headers.getString(key); + connection.setRequestProperty(key, value); + } + } + + /** + * Sets the value of the {@code doOutput} field for this + * {@code URLConnection} to the specified value. + *

+ * A URL connection can be used for input and/or output. Set the DoOutput + * flag to true if you intend to use the URL connection for output, + * false if not. The default is false. + * + * @param shouldDoOutput the new value. + * @throws IllegalStateException if already connected + */ + public void setDoOutput(boolean shouldDoOutput) { + connection.setDoOutput(shouldDoOutput); + } + + /** + * + * @param call + * @throws JSONException + * @throws IOException + */ + public void setRequestBody(PluginCall call, JSValue body) throws JSONException, IOException { + String contentType = connection.getRequestProperty("Content-Type"); + String dataString = ""; + + if (contentType == null || contentType.isEmpty()) return; + + if (contentType.contains("application/json")) { + JSArray jsArray = null; + if (body != null) { + dataString = body.toString(); + } else { + jsArray = call.getArray("data", null); + } + if (jsArray != null) { + dataString = jsArray.toString(); + } else if (body == null) { + dataString = call.getString("data"); + } + this.writeRequestBody(dataString != null ? dataString : ""); + } else if (contentType.contains("application/x-www-form-urlencoded")) { + StringBuilder builder = new StringBuilder(); + + JSObject obj = body.toJSObject(); + Iterator keys = obj.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object d = obj.get(key); + builder.append(key).append("=").append(URLEncoder.encode(d.toString(), "UTF-8")); + + if (keys.hasNext()) { + builder.append("&"); + } + } + this.writeRequestBody(builder.toString()); + } else { + this.writeRequestBody(body.toString()); + } + } + + /** + * Writes the provided string to the HTTP connection managed by this instance. + * + * @param body The string value to write to the connection stream. + */ + private void writeRequestBody(String body) throws IOException { + try (DataOutputStream os = new DataOutputStream(connection.getOutputStream())) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + os.flush(); + } + } + + /** + * Opens a communications link to the resource referenced by this + * URL, if such a connection has not already been established. + *

+ * If the {@code connect} method is called when the connection + * has already been opened (indicated by the {@code connected} + * field having the value {@code true}), the call is ignored. + *

+ * URLConnection objects go through two phases: first they are + * created, then they are connected. After being created, and + * before being connected, various options can be specified + * (e.g., doInput and UseCaches). After connecting, it is an + * error to try to set them. Operations that depend on being + * connected, like getContentLength, will implicitly perform the + * connection, if necessary. + * + * @throws SocketTimeoutException if the timeout expires before + * the connection can be established + * @exception IOException if an I/O error occurs while opening the + * connection. + */ + public void connect() throws IOException { + connection.connect(); + } + + /** + * Gets the status code from an HTTP response message. + * For example, in the case of the following status lines: + *

+     * HTTP/1.0 200 OK
+     * HTTP/1.0 401 Unauthorized
+     * 
+ * It will return 200 and 401 respectively. + * Returns -1 if no code can be discerned + * from the response (i.e., the response is not valid HTTP). + * @throws IOException if an error occurred connecting to the server. + * @return the HTTP Status-Code, or -1 + */ + public int getResponseCode() throws IOException { + return connection.getResponseCode(); + } + + /** + * Returns the value of this {@code URLConnection}'s {@code URL} + * field. + * + * @return the value of this {@code URLConnection}'s {@code URL} + * field. + */ + public URL getURL() { + return connection.getURL(); + } + + /** + * Returns the error stream if the connection failed + * but the server sent useful data nonetheless. The + * typical example is when an HTTP server responds + * with a 404, which will cause a FileNotFoundException + * to be thrown in connect, but the server sent an HTML + * help page with suggestions as to what to do. + * + *

This method will not cause a connection to be initiated. If + * the connection was not connected, or if the server did not have + * an error while connecting or if the server had an error but + * no error data was sent, this method will return null. This is + * the default. + * + * @return an error stream if any, null if there have been no + * errors, the connection is not connected or the server sent no + * useful data. + */ + @Override + public InputStream getErrorStream() { + return connection.getErrorStream(); + } + + /** + * Returns the value of the named header field. + *

+ * If called on a connection that sets the same header multiple times + * with possibly different values, only the last value is returned. + * + * + * @param name the name of a header field. + * @return the value of the named header field, or {@code null} + * if there is no such field in the header. + */ + @Override + public String getHeaderField(String name) { + return connection.getHeaderField(name); + } + + /** + * Returns an input stream that reads from this open connection. + * + * A SocketTimeoutException can be thrown when reading from the + * returned input stream if the read timeout expires before data + * is available for read. + * + * @return an input stream that reads from this open connection. + * @exception IOException if an I/O error occurs while + * creating the input stream. + * @exception UnknownServiceException if the protocol does not support + * input. + * @see #setReadTimeout(int) + */ + @Override + public InputStream getInputStream() throws IOException { + return connection.getInputStream(); + } + + /** + * Returns an unmodifiable Map of the header fields. + * The Map keys are Strings that represent the + * response-header field names. Each Map value is an + * unmodifiable List of Strings that represents + * the corresponding field values. + * + * @return a Map of header fields + */ + public Map> getHeaderFields() { + return connection.getHeaderFields(); + } + + /** + * Sets the default request properties on the newly created connection. + * This is called as early as possible to allow overrides by user-provided values. + */ + private void setDefaultRequestProperties() { + connection.setRequestProperty("Accept-Charset", StandardCharsets.UTF_8.name()); + String acceptLanguage = buildDefaultAcceptLanguageProperty(); + if (!TextUtils.isEmpty(acceptLanguage)) { + connection.setRequestProperty("Accept-Language", acceptLanguage); + } + } + + /** + * Builds and returns a locale string describing the device's current locale preferences. + */ + private String buildDefaultAcceptLanguageProperty() { + Locale locale; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + locale = LocaleList.getDefault().get(0); + } else { + locale = Locale.getDefault(); + } + String result = ""; + String lang = locale.getLanguage(); + String country = locale.getCountry(); + if (!TextUtils.isEmpty(lang)) { + if (!TextUtils.isEmpty(country)) { + result = String.format("%s-%s,%s;q=0.5", lang, country, lang); + } else { + result = String.format("%s;q=0.5", lang); + } + } + return result; + } +} diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java new file mode 100644 index 000000000..0076ebf64 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java @@ -0,0 +1,405 @@ +package com.getcapacitor.plugin.util; + +import android.text.TextUtils; +import android.util.Base64; + +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.JSValue; +import com.getcapacitor.PluginCall; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +public class HttpRequestHandler { + /** + * An enum specifying conventional HTTP Response Types + * See https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType + */ + public enum ResponseType { + ARRAY_BUFFER("arraybuffer"), + BLOB("blob"), + DOCUMENT("document"), + JSON("json"), + TEXT("text"); + + private final String name; + + ResponseType(String name) { + this.name = name; + } + + static final ResponseType DEFAULT = TEXT; + + static ResponseType parse(String value) { + for (ResponseType responseType : values()) { + if (responseType.name.equalsIgnoreCase(value)) { + return responseType; + } + } + return DEFAULT; + } + } + + /** + * Internal builder class for building a CapacitorHttpUrlConnection + */ + private static class HttpURLConnectionBuilder { + + private Integer connectTimeout; + private Integer readTimeout; + private Boolean disableRedirects; + private JSObject headers; + private String method; + private URL url; + + private CapacitorHttpUrlConnection connection; + + public HttpURLConnectionBuilder setConnectTimeout(Integer connectTimeout) { + this.connectTimeout = connectTimeout; + return this; + } + + public HttpURLConnectionBuilder setReadTimeout(Integer readTimeout) { + this.readTimeout = readTimeout; + return this; + } + + public HttpURLConnectionBuilder setDisableRedirects(Boolean disableRedirects) { + this.disableRedirects = disableRedirects; + return this; + } + + public HttpURLConnectionBuilder setHeaders(JSObject headers) { + this.headers = headers; + return this; + } + + public HttpURLConnectionBuilder setMethod(String method) { + this.method = method; + return this; + } + + public HttpURLConnectionBuilder setUrl(URL url) { + this.url = url; + return this; + } + + public HttpURLConnectionBuilder openConnection() throws IOException { + connection = new CapacitorHttpUrlConnection((HttpURLConnection) url.openConnection()); + + connection.setAllowUserInteraction(false); + connection.setRequestMethod(method); + + if (connectTimeout != null) connection.setConnectTimeout(connectTimeout); + if (readTimeout != null) connection.setReadTimeout(readTimeout); + if (disableRedirects != null) connection.setDisableRedirects(disableRedirects); + + connection.setRequestHeaders(headers); + return this; + } + + public HttpURLConnectionBuilder setUrlParams(JSObject params) throws MalformedURLException, URISyntaxException, JSONException { + return this.setUrlParams(params, true); + } + + public HttpURLConnectionBuilder setUrlParams(JSObject params, boolean shouldEncode) + throws URISyntaxException, MalformedURLException { + String initialQuery = url.getQuery(); + String initialQueryBuilderStr = initialQuery == null ? "" : initialQuery; + + Iterator keys = params.keys(); + + if (!keys.hasNext()) { + return this; + } + + StringBuilder urlQueryBuilder = new StringBuilder(initialQueryBuilderStr); + + // Build the new query string + while (keys.hasNext()) { + String key = keys.next(); + + // Attempt as JSONArray and fallback to string if it fails + try { + StringBuilder value = new StringBuilder(); + JSONArray arr = params.getJSONArray(key); + for (int x = 0; x < arr.length(); x++) { + value.append(key).append("=").append(arr.getString(x)); + if (x != arr.length() - 1) { + value.append("&"); + } + } + if (urlQueryBuilder.length() > 0) { + urlQueryBuilder.append("&"); + } + urlQueryBuilder.append(value); + } catch (JSONException e) { + if (urlQueryBuilder.length() > 0) { + urlQueryBuilder.append("&"); + } + urlQueryBuilder.append(key).append("=").append(params.getString(key)); + } + } + + String urlQuery = urlQueryBuilder.toString(); + + URI uri = url.toURI(); + if (shouldEncode) { + URI encodedUri = new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), urlQuery, uri.getFragment()); + this.url = encodedUri.toURL(); + } else { + String unEncodedUrlString = uri.getScheme() + "://" + uri.getAuthority() + uri.getPath() + ((!urlQuery.equals("")) ? "?" + urlQuery : "") + ((uri.getFragment() != null) ? uri.getFragment() : ""); + this.url = new URL(unEncodedUrlString); + } + + return this; + } + + public CapacitorHttpUrlConnection build() { + return connection; + } + } + + /** + * Builds an HTTP Response given CapacitorHttpUrlConnection and ResponseType objects. + * Defaults to ResponseType.DEFAULT + * @param connection The CapacitorHttpUrlConnection to respond with + * @throws IOException Thrown if the InputStream is unable to be parsed correctly + * @throws JSONException Thrown if the JSON is unable to be parsed + */ + private static JSObject buildResponse(CapacitorHttpUrlConnection connection) throws IOException, JSONException { + return buildResponse(connection, ResponseType.DEFAULT); + } + + /** + * Builds an HTTP Response given CapacitorHttpUrlConnection and ResponseType objects + * @param connection The CapacitorHttpUrlConnection to respond with + * @param responseType The requested ResponseType + * @return A JSObject that contains the HTTPResponse to return to the browser + * @throws IOException Thrown if the InputStream is unable to be parsed correctly + * @throws JSONException Thrown if the JSON is unable to be parsed + */ + private static JSObject buildResponse(CapacitorHttpUrlConnection connection, ResponseType responseType) + throws IOException, JSONException { + int statusCode = connection.getResponseCode(); + + JSObject output = new JSObject(); + output.put("status", statusCode); + output.put("headers", buildResponseHeaders(connection)); + output.put("url", connection.getURL()); + output.put("data", readData(connection, responseType)); + + InputStream errorStream = connection.getErrorStream(); + if (errorStream != null) { + output.put("error", true); + } + + return output; + } + + /** + * Read the existing ICapacitorHttpUrlConnection data + * @param connection The ICapacitorHttpUrlConnection object to read in + * @param responseType The type of HTTP response to return to the API + * @return The parsed data from the connection + * @throws IOException Thrown if the InputStreams cannot be properly parsed + * @throws JSONException Thrown if the JSON is malformed when parsing as JSON + */ + static Object readData(ICapacitorHttpUrlConnection connection, ResponseType responseType) throws IOException, JSONException { + InputStream errorStream = connection.getErrorStream(); + String contentType = connection.getHeaderField("Content-Type"); + + if (errorStream != null) { + if (isOneOf(contentType, MimeType.APPLICATION_JSON, MimeType.APPLICATION_VND_API_JSON)) { + return parseJSON(readStreamAsString(errorStream)); + } else { + return readStreamAsString(errorStream); + } + } else if (contentType != null && contentType.contains(MimeType.APPLICATION_JSON.getValue())) { + // backward compatibility + return parseJSON(readStreamAsString(connection.getInputStream())); + } else { + InputStream stream = connection.getInputStream(); + switch (responseType) { + case ARRAY_BUFFER: + case BLOB: + return readStreamAsBase64(stream); + case JSON: + return parseJSON(readStreamAsString(stream)); + case DOCUMENT: + case TEXT: + default: + return readStreamAsString(stream); + } + } + } + + /** + * Helper function for determining if the Content-Type is a typeof an existing Mime-Type + * @param contentType The Content-Type string to check for + * @param mimeTypes The Mime-Type values to check against + * @return + */ + private static boolean isOneOf(String contentType, MimeType... mimeTypes) { + if (contentType != null) { + for (MimeType mimeType : mimeTypes) { + if (contentType.contains(mimeType.getValue())) { + return true; + } + } + } + return false; + } + + /** + * Build the JSObject response headers based on the connection header map + * @param connection The CapacitorHttpUrlConnection connection + * @return A JSObject of the header values from the CapacitorHttpUrlConnection + */ + private static JSObject buildResponseHeaders(CapacitorHttpUrlConnection connection) { + JSObject output = new JSObject(); + + for (Map.Entry> entry : connection.getHeaderFields().entrySet()) { + String valuesString = TextUtils.join(", ", entry.getValue()); + output.put(entry.getKey(), valuesString); + } + + return output; + } + + /** + * Returns a JSObject or a JSArray based on a string-ified input + * @param input String-ified JSON that needs parsing + * @return A JSObject or JSArray + * @throws JSONException thrown if the JSON is malformed + */ + private static Object parseJSON(String input) throws JSONException { + JSONObject json = new JSONObject(); + try { + if ("null".equals(input.trim())) { + return JSONObject.NULL; + } else if ("true".equals(input.trim())) { + return new JSONObject().put("flag", "true"); + } else if ("false".equals(input.trim())) { + return new JSONObject().put("flag", "false"); + } else { + try { + return new JSObject(input); + } catch (JSONException e) { + return new JSArray(input); + } + } + } catch (JSONException e) { + return new JSArray(input); + } + } + + /** + * Returns a string based on a base64 InputStream + * @param in The base64 InputStream to convert to a String + * @return String value of InputStream + * @throws IOException thrown if the InputStream is unable to be read as base64 + */ + private static String readStreamAsBase64(InputStream in) throws IOException { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[1024]; + int readBytes; + while ((readBytes = in.read(buffer)) != -1) { + out.write(buffer, 0, readBytes); + } + byte[] result = out.toByteArray(); + return Base64.encodeToString(result, 0, result.length, Base64.DEFAULT); + } + } + + /** + * Returns a string based on an InputStream + * @param in The InputStream to convert to a String + * @return String value of InputStream + * @throws IOException thrown if the InputStream is unable to be read + */ + private static String readStreamAsString(InputStream in) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) { + StringBuilder builder = new StringBuilder(); + String line = reader.readLine(); + while (line != null) { + builder.append(line); + line = reader.readLine(); + if (line != null) { + builder.append(System.getProperty("line.separator")); + } + } + return builder.toString(); + } + } + + /** + * Makes an Http Request based on the PluginCall parameters + * @param call The Capacitor PluginCall that contains the options need for an Http request + * @param httpMethod The HTTP method that overrides the PluginCall HTTP method + * @throws IOException throws an IO request when a connection can't be made + * @throws URISyntaxException thrown when the URI is malformed + * @throws JSONException thrown when the incoming JSON is malformed + */ + public static JSObject request(PluginCall call, String httpMethod) throws IOException, URISyntaxException, JSONException { + String urlString = call.getString("url", ""); + JSObject headers = call.getObject("headers"); + JSObject params = call.getObject("params"); + Integer connectTimeout = call.getInt("connectTimeout"); + Integer readTimeout = call.getInt("readTimeout"); + Boolean disableRedirects = call.getBoolean("disableRedirects"); + Boolean shouldEncode = call.getBoolean("shouldEncodeUrlParams", true); + ResponseType responseType = ResponseType.parse(call.getString("responseType")); + + String method = httpMethod != null ? httpMethod.toUpperCase() : call.getString("method", "").toUpperCase(); + + boolean isHttpMutate = method.equals("DELETE") || method.equals("PATCH") || method.equals("POST") || method.equals("PUT"); + + URL url = new URL(urlString); + HttpURLConnectionBuilder connectionBuilder = new HttpURLConnectionBuilder() + .setUrl(url) + .setMethod(method) + .setHeaders(headers) + .setUrlParams(params, shouldEncode) + .setConnectTimeout(connectTimeout) + .setReadTimeout(readTimeout) + .setDisableRedirects(disableRedirects) + .openConnection(); + + CapacitorHttpUrlConnection connection = connectionBuilder.build(); + + // Set HTTP body on a non GET or HEAD request + if (isHttpMutate) { + JSValue data = new JSValue(call, "data"); + if (data.getValue() != null) { + connection.setDoOutput(true); + connection.setRequestBody(call, data); + } + } + + connection.connect(); + + return buildResponse(connection, responseType); + } + + @FunctionalInterface + public interface ProgressEmitter { + void emit(Integer bytes, Integer contentLength); + } +} diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java new file mode 100644 index 000000000..9654a77c4 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java @@ -0,0 +1,15 @@ +package com.getcapacitor.plugin.util; + +import java.io.IOException; +import java.io.InputStream; + +/** + * This interface was extracted from {@link CapacitorHttpUrlConnection} to enable mocking that class. + */ +interface ICapacitorHttpUrlConnection { + InputStream getErrorStream(); + + String getHeaderField(String name); + + InputStream getInputStream() throws IOException; +} \ No newline at end of file diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/MimeType.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/MimeType.java new file mode 100644 index 000000000..cfc90f824 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/MimeType.java @@ -0,0 +1,17 @@ +package com.getcapacitor.plugin.util; + +enum MimeType { + APPLICATION_JSON("application/json"), + APPLICATION_VND_API_JSON("application/vnd.api+json"), // https://jsonapi.org + TEXT_HTML("text/html"); + + private final String value; + + MimeType(String value) { + this.value = value; + } + + String getValue() { + return value; + } +} diff --git a/core/native-bridge.ts b/core/native-bridge.ts index e13628fe4..2d1ba0385 100644 --- a/core/native-bridge.ts +++ b/core/native-bridge.ts @@ -2,6 +2,7 @@ * Note: When making changes to this file, run `npm run build:nativebridge` * afterwards to build the nativebridge.js files to the android and iOS projects. */ +import type { HttpResponse } from './src/core-plugins'; import type { CallData, CapacitorInstance, @@ -11,6 +12,7 @@ import type { WindowCapacitor, } from './src/definitions-internal'; + // For removing exports for iOS/Android, keep let for reassignment // eslint-disable-next-line let dummy = {}; @@ -286,6 +288,176 @@ const initBridge = (w: any): void => { return String(msg); }; + const platform = getPlatformId(win); + + // TODO: Check cap config for opt-out + // patch fetch / XHR on Android/iOS + if (platform == 'android' || platform == 'ios') { + // store original fetch & XHR functions + win.CapacitorWebFetch = window.fetch; + win.CapacitorWebXMLHttpRequest = { + abort: window.XMLHttpRequest.prototype.abort, + open: window.XMLHttpRequest.prototype.open, + send: window.XMLHttpRequest.prototype.send, + setRequestHeader: window.XMLHttpRequest.prototype.setRequestHeader, + }; + + // fetch patch + window.fetch = async (resource: RequestInfo | URL, options?: RequestInit) => { + try { + // intercept request & pass to the bridge + const nativeResponse: HttpResponse = await cap.nativePromise('CapacitorHttp', 'request', { + url: resource, + method: options?.method ? options.method : undefined, + data: options?.body ? options.body : undefined, + headers: options?.headers ? options.headers : undefined, + }); + + // intercept & parse response before returning + const response = new Response(JSON.stringify(nativeResponse.data), { + headers: nativeResponse.headers, + status: nativeResponse.status, + }); + + return response; + } + catch (error) { + return Promise.reject(error); + } + }; + + // XHR event listeners + const addEventListeners = function() { + this.addEventListener('abort', function () { + if(typeof this.onabort === 'function') this.onabort(); + }); + + this.addEventListener('error', function () { + if(typeof this.onerror === 'function') this.onerror(); + }); + + this.addEventListener('load', function () { + if(typeof this.onload === 'function') this.onload(); + }); + + this.addEventListener('loadend', function () { + if(typeof this.onloadend === 'function') this.onloadend(); + }); + + this.addEventListener('loadstart', function () { + if(typeof this.onloadstart === 'function') this.onloadstart(); + }); + + this.addEventListener('readystatechange', function () { + if(typeof this.onreadystatechange === 'function') this.onreadystatechange(); + }); + + this.addEventListener('timeout', function () { + if(typeof this.ontimeout === 'function') this.ontimeout(); + }); + } + + // XHR patch abort + window.XMLHttpRequest.prototype.abort = function() { + Object.defineProperties(this, { + _headers: { + value: {}, + writable: true, + }, + readyState: { + get: function() { + return this._readyState ?? 0; + }, + set: function (val: number) { + this._readyState = val; + this.dispatchEvent(new Event('readystatechange')); + } + }, + response: { + value: '', + writable: true, + }, + responseText: { + value: '', + writable: true, + }, + responseURL: { + value: '', + writable: true, + }, + status: { + value: 0, + writable: true, + }, + }); + this.readyState = 0; + this.dispatchEvent(new Event('abort')); + this.dispatchEvent(new Event('loadend')); + } + + // XHR patch open + window.XMLHttpRequest.prototype.open = function (method: string, url: string) { + this.abort(); + addEventListeners.call(this); + this._method = method; + this._url = url; + this.readyState = 1; + }; + + // XHR patch set request header + window.XMLHttpRequest.prototype.setRequestHeader = function (header: string, value: string) { + this._headers[header] = value; + }; + + // XHR patch send + window.XMLHttpRequest.prototype.send = function ( + body?: Document | XMLHttpRequestBodyInit + ) { + try { + this.readyState = 2; + + // intercept request & pass to the bridge + cap.nativePromise('CapacitorHttp', 'request', { + url: this._url, + method: this._method, + data: (body !== null) ? body : undefined, + headers: this._headers, + }).then((nativeResponse: any) => { + // intercept & parse response before returning + if (this.readyState == 2) { + this.dispatchEvent(new Event('loadstart')); + this.status = nativeResponse.status; + this.response = nativeResponse.data; + this.responseText = JSON.stringify(nativeResponse.data); + this.responseURL = nativeResponse.url; + this.readyState = 4; + this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('loadend')); + } + }).catch((error: any) => { + this.dispatchEvent(new Event('loadstart')); + this.status = error.status; + this.response = error.data; + this.responseText = JSON.stringify(error.data); + this.responseURL = error.url; + this.readyState = 4; + this.dispatchEvent(new Event('error')); + this.dispatchEvent(new Event('loadend')); + }); + } + catch (error) { + this.dispatchEvent(new Event('loadstart')); + this.status = 500; + this.response = error; + this.responseText = error.toString(); + this.responseURL = this._url; + this.readyState = 4; + this.dispatchEvent(new Event('error')); + this.dispatchEvent(new Event('loadend')); + } + } + } + // patch window.console on iOS and store original console fns const isIos = getPlatformId(win) === 'ios'; if (win.console && isIos) { diff --git a/core/src/core-plugins.ts b/core/src/core-plugins.ts index beda630c0..d4ddfe644 100644 --- a/core/src/core-plugins.ts +++ b/core/src/core-plugins.ts @@ -1,5 +1,6 @@ import type { Plugin } from './definitions'; import { registerPlugin } from './global'; +import { WebPlugin } from './web-plugin'; export interface WebViewPlugin extends Plugin { setServerBasePath(options: WebViewPath): Promise; @@ -12,3 +13,277 @@ export interface WebViewPath { } export const WebView = /*#__PURE__*/ registerPlugin('WebView'); + +/******** HTTP PLUGIN ********/ +export interface CapacitorHttpPlugin { + request(options: HttpOptions): Promise; + get(options: HttpOptions): Promise; + post(options: HttpOptions): Promise; + put(options: HttpOptions): Promise; + patch(options: HttpOptions): Promise; + delete(options: HttpOptions): Promise; +} + +export type HttpResponseType = 'arraybuffer' | 'blob' | 'json' | 'text' | 'document'; + +export interface HttpOptions { + url: string; + method?: string; + params?: HttpParams; + data?: any; + headers?: HttpHeaders; + /** + * How long to wait to read additional data. Resets each time new + * data is received + */ + readTimeout?: number; + /** + * How long to wait for the initial connection. + */ + connectTimeout?: number; + /** + * Sets whether automatic HTTP redirects should be disabled + */ + disableRedirects?: boolean; + /** + * Extra arguments for fetch when running on the web + */ + webFetchExtra?: RequestInit; + /** + * This is used to parse the response appropriately before returning it to + * the requestee. If the response content-type is "json", this value is ignored. + */ + responseType?: HttpResponseType; + /** + * Use this option if you need to keep the URL unencoded in certain cases + * (already encoded, azure/firebase testing, etc.). The default is _true_. + */ + shouldEncodeUrlParams?: boolean; +} + +export interface HttpParams { + [key: string]: string | string[]; +} + +export interface HttpHeaders { + [key: string]: string; +} + +export interface HttpResponse { + data: any; + status: number; + headers: HttpHeaders; + url: string; +} + +// UTILITY FUNCTIONS + +/** + * Normalize an HttpHeaders map by lowercasing all of the values + * @param headers The HttpHeaders object to normalize + */ +const normalizeHttpHeaders = (headers: HttpHeaders = {}): HttpHeaders => { + const originalKeys = Object.keys(headers); + const loweredKeys = Object.keys(headers).map(k => k.toLocaleLowerCase()); + const normalized = loweredKeys.reduce((acc, key, index) => { + acc[key] = headers[originalKeys[index]]; + return acc; + }, {}); + return normalized; +}; + +/** + * Builds a string of url parameters that + * @param params A map of url parameters + * @param shouldEncode true if you should encodeURIComponent() the values (true by default) + */ +const buildUrlParams = ( + params?: HttpParams, + shouldEncode = true, +): string | null => { + if (!params) return null; + + const output = Object.entries(params).reduce((accumulator, entry) => { + const [key, value] = entry; + + let encodedValue: string; + let item: string; + if (Array.isArray(value)) { + item = ''; + value.forEach(str => { + encodedValue = shouldEncode ? encodeURIComponent(str) : str; + item += `${key}=${encodedValue}&`; + }); + // last character will always be "&" so slice it off + item.slice(0, -1); + } else { + encodedValue = shouldEncode ? encodeURIComponent(value) : value; + item = `${key}=${encodedValue}`; + } + + return `${accumulator}&${item}`; + }, ''); + + // Remove initial "&" from the reduce + return output.substr(1); +}; + +/** + * Build the RequestInit object based on the options passed into the initial request + * @param options The Http plugin options + * @param extra Any extra RequestInit values + */ +export const buildRequestInit = ( + options: HttpOptions, + extra: RequestInit = {}, +): RequestInit => { + const output: RequestInit = { + method: options.method || 'GET', + headers: options.headers, + ...extra, + }; + + // Get the content-type + const headers = normalizeHttpHeaders(options.headers); + const type = headers['content-type'] || ''; + + // If body is already a string, then pass it through as-is. + if (typeof options.data === 'string') { + output.body = options.data; + } + // Build request initializers based off of content-type + else if (type.includes('application/x-www-form-urlencoded')) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(options.data || {})) { + params.set(key, value as any); + } + output.body = params.toString(); + } else if (type.includes('multipart/form-data')) { + const form = new FormData(); + if (options.data instanceof FormData) { + options.data.forEach((value, key) => { + form.append(key, value); + }); + } else { + for (const key of Object.keys(options.data)) { + form.append(key, options.data[key]); + } + } + output.body = form; + const headers = new Headers(output.headers); + headers.delete('content-type'); // content-type will be set by `window.fetch` to includy boundary + output.headers = headers; + } else if ( + type.includes('application/json') || + typeof options.data === 'object' + ) { + output.body = JSON.stringify(options.data); + } + + return output; +}; + +// WEB IMPLEMENTATION +export class CapacitorHttpPluginWeb extends WebPlugin implements CapacitorHttpPlugin { + /** + * Perform an Http request given a set of options + * @param options Options to build the HTTP request + */ + async request(options: HttpOptions): Promise { + const requestInit = buildRequestInit(options, options.webFetchExtra); + const urlParams = buildUrlParams( + options.params, + options.shouldEncodeUrlParams, + ); + const url = urlParams ? `${options.url}?${urlParams}` : options.url; + + const response = await fetch(url, requestInit); + const contentType = response.headers.get('content-type') || ''; + + // Default to 'text' responseType so no parsing happens + let { responseType = 'text' } = response.ok ? options : {}; + + // If the response content-type is json, force the response to be json + if (contentType.includes('application/json')) { + responseType = 'json'; + } + + let data: any; + switch (responseType) { + case 'arraybuffer': + case 'blob': + //TODO: Add Blob Support + break; + case 'json': + data = await response.json(); + break; + case 'document': + case 'text': + default: + data = await response.text(); + } + + // Convert fetch headers to Capacitor HttpHeaders + const headers = {} as HttpHeaders; + response.headers.forEach((value: string, key: string) => { + headers[key] = value; + }); + + return { + data, + headers, + status: response.status, + url: response.url, + }; + } + + /** + * Perform an Http GET request given a set of options + * @param options Options to build the HTTP request + */ + async get(options: HttpOptions): Promise { + return this.request({ ...options, method: 'GET' }); + } + + /** + * Perform an Http POST request given a set of options + * @param options Options to build the HTTP request + */ + async post(options: HttpOptions): Promise { + return this.request({ ...options, method: 'POST' }); + } + + /** + * Perform an Http PUT request given a set of options + * @param options Options to build the HTTP request + */ + async put(options: HttpOptions): Promise { + return this.request({ ...options, method: 'PUT' }); + } + + /** + * Perform an Http PATCH request given a set of options + * @param options Options to build the HTTP request + */ + async patch(options: HttpOptions): Promise { + return this.request({ ...options, method: 'PATCH' }); + } + + /** + * Perform an Http DELETE request given a set of options + * @param options Options to build the HTTP request + */ + async delete(options: HttpOptions): Promise { + return this.request({ ...options, method: 'DELETE' }); + } + +} + +export const CapacitorHttp = registerPlugin( + 'CapacitorHttp', + { + web: () => new CapacitorHttpPluginWeb(), + }, +); + +/******** END HTTP PLUGIN ********/ \ No newline at end of file diff --git a/core/src/definitions-internal.ts b/core/src/definitions-internal.ts index 6137d3dec..03076425e 100644 --- a/core/src/definitions-internal.ts +++ b/core/src/definitions-internal.ts @@ -172,6 +172,8 @@ export interface CapacitorCustomPlatformInstance { export interface WindowCapacitor { Capacitor?: CapacitorInstance; + CapacitorWebFetch?: any; + CapacitorWebXMLHttpRequest?: any; /** * @deprecated Use `CapacitorCustomPlatform` instead */ diff --git a/core/src/index.ts b/core/src/index.ts index 303012a5c..2449f6ea2 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -20,10 +20,18 @@ export { Capacitor, registerPlugin } from './global'; export { WebPlugin, WebPluginConfig, ListenerCallback } from './web-plugin'; // Core Plugins APIs -export { WebView } from './core-plugins'; +export { CapacitorHttp, WebView } from './core-plugins'; // Core Plugin definitions -export type { WebViewPath, WebViewPlugin } from './core-plugins'; +export type { + HttpHeaders, + HttpOptions, + HttpParams, + HttpResponse, + HttpResponseType, + WebViewPath, + WebViewPlugin, +} from './core-plugins'; // Constants export { CapacitorException, ExceptionCode } from './util'; diff --git a/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift b/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift new file mode 100644 index 000000000..6043bad65 --- /dev/null +++ b/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift @@ -0,0 +1,41 @@ +import Foundation + +@objc(CAPHttpPlugin) +public class CAPHttpPlugin: CAPPlugin { + @objc func http(_ call: CAPPluginCall, _ httpMethod: String?) { + // Protect against bad values from JS before calling request + guard let u = call.getString("url") else { return call.reject("Must provide a URL"); } + guard let _ = httpMethod ?? call.getString("method") else { return call.reject("Must provide an HTTP Method"); } + guard var _ = URL(string: u) else { return call.reject("Invalid URL"); } + + do { + try HttpRequestHandler.request(call, httpMethod) + } catch let e { + call.reject(e.localizedDescription) + } + } + + @objc func request(_ call: CAPPluginCall) { + http(call, nil) + } + + @objc func get(_ call: CAPPluginCall) { + http(call, "GET") + } + + @objc func post(_ call: CAPPluginCall) { + http(call, "POST") + } + + @objc func put(_ call: CAPPluginCall) { + http(call, "PUT") + } + + @objc func patch(_ call: CAPPluginCall) { + http(call, "PATCH") + } + + @objc func del(_ call: CAPPluginCall) { + http(call, "DELETE") + } +} diff --git a/ios/Capacitor/Capacitor/Plugins/CapacitorUrlRequest.swift b/ios/Capacitor/Capacitor/Plugins/CapacitorUrlRequest.swift new file mode 100644 index 000000000..32cc6b733 --- /dev/null +++ b/ios/Capacitor/Capacitor/Plugins/CapacitorUrlRequest.swift @@ -0,0 +1,151 @@ +import Foundation + +public class CapacitorUrlRequest: NSObject, URLSessionTaskDelegate { + private var request: URLRequest; + private var headers: [String:String]; + + enum CapacitorUrlRequestError: Error { + case serializationError(String?) + } + + init(_ url: URL, method: String) { + request = URLRequest(url: url) + request.httpMethod = method + headers = [:] + if let lang = Locale.autoupdatingCurrent.languageCode { + if let country = Locale.autoupdatingCurrent.regionCode { + headers["Accept-Language"] = "\(lang)-\(country),\(lang);q=0.5" + } else { + headers["Accept-Language"] = "\(lang);q=0.5" + } + request.addValue(headers["Accept-Language"]!, forHTTPHeaderField: "Accept-Language") + } + } + + private func getRequestDataAsJson(_ data: JSValue) throws -> Data? { + // We need to check if the JSON is valid before attempting to serialize, as JSONSerialization.data will not throw an exception that can be caught, and will cause the application to crash if it fails. + if JSONSerialization.isValidJSONObject(data) { + return try JSONSerialization.data(withJSONObject: data) + } else { + throw CapacitorUrlRequest.CapacitorUrlRequestError.serializationError("[ data ] argument for request of content-type [ application/json ] must be serializable to JSON") + } + } + + private func getRequestDataAsFormUrlEncoded(_ data: JSValue) throws -> Data? { + guard var components = URLComponents(url: request.url!, resolvingAgainstBaseURL: false) else { return nil } + components.queryItems = [] + + guard let obj = data as? JSObject else { + // Throw, other data types explicitly not supported + throw CapacitorUrlRequestError.serializationError("[ data ] argument for request with content-type [ multipart/form-data ] may only be a plain javascript object") + } + + obj.keys.forEach { (key: String) in + components.queryItems?.append(URLQueryItem(name: key, value: "\(obj[key] ?? "")")) + } + + + if components.query != nil { + return Data(components.query!.utf8) + } + + return nil + } + + private func getRequestDataAsMultipartFormData(_ data: JSValue) throws -> Data { + guard let obj = data as? JSObject else { + // Throw, other data types explicitly not supported. + throw CapacitorUrlRequestError.serializationError("[ data ] argument for request with content-type [ application/x-www-form-urlencoded ] may only be a plain javascript object") + } + + let strings: [String: String] = obj.compactMapValues { any in + any as? String + } + + var data = Data() + let boundary = UUID().uuidString + let contentType = "multipart/form-data; boundary=\(boundary)" + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + headers["Content-Type"] = contentType + + strings.forEach { key, value in + data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) + data.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + data.append(value.data(using: .utf8)!) + } + data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + return data + } + + private func getRequestDataAsString(_ data: JSValue) throws -> Data { + guard let stringData = data as? String else { + throw CapacitorUrlRequestError.serializationError("[ data ] argument could not be parsed as string") + } + return Data(stringData.utf8) + } + + func getRequestHeader(_ index: String) -> Any? { + var normalized = [:] as [String:Any] + self.headers.keys.forEach { (key: String) in + normalized[key.lowercased()] = self.headers[key] + } + + return normalized[index.lowercased()] + } + + func getRequestData(_ body: JSValue, _ contentType: String) throws -> Data? { + // If data can be parsed directly as a string, return that without processing. + if let strVal = try? getRequestDataAsString(body) { + return strVal + } else if contentType.contains("application/json") { + return try getRequestDataAsJson(body) + } else if contentType.contains("application/x-www-form-urlencoded") { + return try getRequestDataAsFormUrlEncoded(body) + } else if contentType.contains("multipart/form-data") { + return try getRequestDataAsMultipartFormData(body) + } else { + throw CapacitorUrlRequestError.serializationError("[ data ] argument could not be parsed for content type [ \(contentType) ]") + } + } + + public func setRequestHeaders(_ headers: [String: String]) { + headers.keys.forEach { (key: String) in + let value = headers[key] + request.addValue(value!, forHTTPHeaderField: key) + self.headers[key] = value + } + } + + public func setRequestBody(_ body: JSValue) throws { + let contentType = self.getRequestHeader("Content-Type") as? String + + if contentType != nil { + request.httpBody = try getRequestData(body, contentType!) + } + } + + public func setContentType(_ data: String?) { + request.setValue(data, forHTTPHeaderField: "Content-Type") + } + + public func setTimeout(_ timeout: TimeInterval) { + request.timeoutInterval = timeout; + } + + public func getUrlRequest() -> URLRequest { + return request; + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + completionHandler(nil) + } + + public func getUrlSession(_ call: CAPPluginCall) -> URLSession { + let disableRedirects = call.getBool("disableRedirects") ?? false + if (!disableRedirects) { + return URLSession.shared + } + return URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: nil) + } +} diff --git a/ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m b/ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m index 21abbef22..6aa071f1d 100644 --- a/ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m +++ b/ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m @@ -6,6 +6,15 @@ CAP_PLUGIN_METHOD(log, CAPPluginReturnNone); ) +CAP_PLUGIN(CAPHttpPlugin, "CapacitorHttp", + CAP_PLUGIN_METHOD(request, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(get, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(post, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(put, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(patch, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(delete, CAPPluginReturnPromise); +) + CAP_PLUGIN(CAPWebViewPlugin, "WebView", CAP_PLUGIN_METHOD(setServerBasePath, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getServerBasePath, CAPPluginReturnPromise); diff --git a/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift b/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift new file mode 100644 index 000000000..68e95d413 --- /dev/null +++ b/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift @@ -0,0 +1,173 @@ +import Foundation + +/// See https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType +fileprivate enum ResponseType: String { + case arrayBuffer = "arraybuffer" + case blob = "blob" + case document = "document" + case json = "json" + case text = "text" + + static let `default`: ResponseType = .text + + init(string: String?) { + guard let string = string else { + self = .default + return + } + + guard let responseType = ResponseType(rawValue: string.lowercased()) else { + self = .default + return + } + + self = responseType + } +} + +/// Helper that safely parses JSON Data. Otherwise returns an error (without throwing) +/// - Parameters: +/// - data: The JSON Data to parse +/// - Returns: The parsed value or an error +func tryParseJson(_ data: Data) -> Any { + do { + return try JSONSerialization.jsonObject(with: data, options: .mutableContainers) + } catch { + return error.localizedDescription + } +} + +class HttpRequestHandler { + private class CapacitorHttpRequestBuilder { + private var url: URL? + private var method: String? + private var params: [String:String]? + private var request: CapacitorUrlRequest? + + /// Set the URL of the HttpRequest + /// - Throws: an error of URLError if the urlString cannot be parsed + /// - Parameters: + /// - urlString: The URL value to parse + /// - Returns: self to continue chaining functions + public func setUrl(_ urlString: String) throws -> CapacitorHttpRequestBuilder { + guard let u = URL(string: urlString) else { + throw URLError(.badURL) + } + url = u + return self + } + + public func setMethod(_ method: String) -> CapacitorHttpRequestBuilder { + self.method = method; + return self + } + + public func setUrlParams(_ params: [String:Any]) -> CapacitorHttpRequestBuilder { + if (params.count != 0) { + var cmps = URLComponents(url: url!, resolvingAgainstBaseURL: true) + if cmps?.queryItems == nil { + cmps?.queryItems = [] + } + + var urlSafeParams: [URLQueryItem] = [] + for (key, value) in params { + if let arr = value as? [String] { + arr.forEach { str in + urlSafeParams.append(URLQueryItem(name: key, value: str)) + } + } else { + urlSafeParams.append(URLQueryItem(name: key, value: (value as! String))) + } + } + + cmps!.queryItems?.append(contentsOf: urlSafeParams) + url = cmps!.url! + } + return self + } + + public func openConnection() -> CapacitorHttpRequestBuilder { + request = CapacitorUrlRequest(url!, method: method!) + return self + } + + public func build() -> CapacitorUrlRequest { + return request! + } + } + + private static func buildResponse(_ data: Data?, _ response: HTTPURLResponse, responseType: ResponseType = .default) -> [String:Any] { + var output = [:] as [String:Any] + + output["status"] = response.statusCode + output["headers"] = response.allHeaderFields + output["url"] = response.url?.absoluteString + + guard let data = data else { + output["data"] = "" + return output + } + + let contentType = (response.allHeaderFields["Content-Type"] as? String ?? "application/default").lowercased(); + + if (contentType.contains("application/json") || responseType == .json) { + output["data"] = tryParseJson(data); + } else if (responseType == .arrayBuffer || responseType == .blob) { + output["data"] = data.base64EncodedString(); + } else if (responseType == .document || responseType == .text || responseType == .default) { + output["data"] = String(data: data, encoding: .utf8) + } + + return output + } + + + public static func request(_ call: CAPPluginCall, _ httpMethod: String?) throws { + guard let urlString = call.getString("url") else { throw URLError(.badURL) } + guard let method = httpMethod ?? call.getString("method") else { throw URLError(.dataNotAllowed) } + + let headers = (call.getObject("headers") ?? [:]) as! [String: String] + let params = (call.getObject("params") ?? [:]) as! [String: Any] + let responseType = call.getString("responseType") ?? "text"; + let connectTimeout = call.getDouble("connectTimeout"); + let readTimeout = call.getDouble("readTimeout"); + + let request = try! CapacitorHttpRequestBuilder() + .setUrl(urlString) + .setMethod(method) + .setUrlParams(params) + .openConnection() + .build(); + + request.setRequestHeaders(headers) + + // Timeouts in iOS are in seconds. So read the value in millis and divide by 1000 + let timeout = (connectTimeout ?? readTimeout ?? 600000.0) / 1000.0; + request.setTimeout(timeout) + + if let data = call.options["data"] as? JSValue { + do { + try request.setRequestBody(data) + } catch { + // Explicitly reject if the http request body was not set successfully, + // so as to not send a known malformed request, and to provide the developer with additional context. + call.reject("Error", "REQUEST", error, [:]) + return + } + } + + let urlRequest = request.getUrlRequest(); + let urlSession = request.getUrlSession(call); + let task = urlSession.dataTask(with: urlRequest) { (data, response, error) in + urlSession.invalidateAndCancel(); + if error != nil { + return + } + + let type = ResponseType(rawValue: responseType) ?? .default + call.resolve(self.buildResponse(data, response as! HTTPURLResponse, responseType: type)) + } + + task.resume(); + } +} diff --git a/ios/Capacitor/Capacitor/assets/native-bridge.js b/ios/Capacitor/Capacitor/assets/native-bridge.js index 30e97b22c..e4ab6ef06 100644 --- a/ios/Capacitor/Capacitor/assets/native-bridge.js +++ b/ios/Capacitor/Capacitor/assets/native-bridge.js @@ -241,6 +241,165 @@ const nativeBridge = (function (exports) { } return String(msg); }; + const platform = getPlatformId(win); + // TODO: Check cap config for opt-out + // patch fetch / XHR on Android/iOS + if (platform == 'android' || platform == 'ios') { + // store original fetch & XHR functions + win.CapacitorWebFetch = window.fetch; + win.CapacitorWebXMLHttpRequest = { + abort: window.XMLHttpRequest.prototype.abort, + open: window.XMLHttpRequest.prototype.open, + send: window.XMLHttpRequest.prototype.send, + setRequestHeader: window.XMLHttpRequest.prototype.setRequestHeader, + }; + // fetch patch + window.fetch = async (resource, options) => { + try { + // intercept request & pass to the bridge + const nativeResponse = await cap.nativePromise('CapacitorHttp', 'request', { + url: resource, + method: (options === null || options === void 0 ? void 0 : options.method) ? options.method : undefined, + data: (options === null || options === void 0 ? void 0 : options.body) ? options.body : undefined, + headers: (options === null || options === void 0 ? void 0 : options.headers) ? options.headers : undefined, + }); + // intercept & parse response before returning + const response = new Response(JSON.stringify(nativeResponse.data), { + headers: nativeResponse.headers, + status: nativeResponse.status, + }); + return response; + } + catch (error) { + return Promise.reject(error); + } + }; + // XHR event listeners + const addEventListeners = function () { + this.addEventListener('abort', function () { + if (typeof this.onabort === 'function') + this.onabort(); + }); + this.addEventListener('error', function () { + if (typeof this.onerror === 'function') + this.onerror(); + }); + this.addEventListener('load', function () { + if (typeof this.onload === 'function') + this.onload(); + }); + this.addEventListener('loadend', function () { + if (typeof this.onloadend === 'function') + this.onloadend(); + }); + this.addEventListener('loadstart', function () { + if (typeof this.onloadstart === 'function') + this.onloadstart(); + }); + this.addEventListener('readystatechange', function () { + if (typeof this.onreadystatechange === 'function') + this.onreadystatechange(); + }); + this.addEventListener('timeout', function () { + if (typeof this.ontimeout === 'function') + this.ontimeout(); + }); + }; + // XHR patch abort + window.XMLHttpRequest.prototype.abort = function () { + Object.defineProperties(this, { + _headers: { + value: {}, + writable: true, + }, + readyState: { + get: function () { + var _a; + return (_a = this._readyState) !== null && _a !== void 0 ? _a : 0; + }, + set: function (val) { + this._readyState = val; + this.dispatchEvent(new Event('readystatechange')); + } + }, + response: { + value: '', + writable: true, + }, + responseText: { + value: '', + writable: true, + }, + responseURL: { + value: '', + writable: true, + }, + status: { + value: 0, + writable: true, + }, + }); + this.readyState = 0; + this.dispatchEvent(new Event('abort')); + this.dispatchEvent(new Event('loadend')); + }; + // XHR patch open + window.XMLHttpRequest.prototype.open = function (method, url) { + this.abort(); + addEventListeners.call(this); + this._method = method; + this._url = url; + this.readyState = 1; + }; + // XHR patch set request header + window.XMLHttpRequest.prototype.setRequestHeader = function (header, value) { + this._headers[header] = value; + }; + // XHR patch send + window.XMLHttpRequest.prototype.send = function (body) { + try { + this.readyState = 2; + // intercept request & pass to the bridge + cap.nativePromise('CapacitorHttp', 'request', { + url: this._url, + method: this._method, + data: (body !== null) ? body : undefined, + headers: this._headers, + }).then((nativeResponse) => { + // intercept & parse response before returning + if (this.readyState == 2) { + this.dispatchEvent(new Event('loadstart')); + this.status = nativeResponse.status; + this.response = nativeResponse.data; + this.responseText = JSON.stringify(nativeResponse.data); + this.responseURL = nativeResponse.url; + this.readyState = 4; + this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('loadend')); + } + }).catch((error) => { + this.dispatchEvent(new Event('loadstart')); + this.status = error.status; + this.response = error.data; + this.responseText = JSON.stringify(error.data); + this.responseURL = error.url; + this.readyState = 4; + this.dispatchEvent(new Event('error')); + this.dispatchEvent(new Event('loadend')); + }); + } + catch (error) { + this.dispatchEvent(new Event('loadstart')); + this.status = 500; + this.response = error; + this.responseText = error.toString(); + this.responseURL = this._url; + this.readyState = 4; + this.dispatchEvent(new Event('error')); + this.dispatchEvent(new Event('loadend')); + } + }; + } // patch window.console on iOS and store original console fns const isIos = getPlatformId(win) === 'ios'; if (win.console && isIos) { From 23d7c0f6c418e9c7284cc406e435e3ff8c0ae372 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Tue, 30 Aug 2022 08:06:59 -0700 Subject: [PATCH 02/15] chore: run build and run fmt --- .../src/main/assets/native-bridge.js | 13 +- .../main/java/com/getcapacitor/JSValue.java | 2 +- .../getcapacitor/plugin/CapacitorHttp.java | 6 +- .../util/CapacitorHttpUrlConnection.java | 5 +- .../plugin/util/HttpRequestHandler.java | 38 ++--- .../util/ICapacitorHttpUrlConnection.java | 2 +- core/native-bridge.ts | 134 ++++++++++-------- core/src/core-plugins.ts | 101 +++++++------ .../Capacitor/Plugins/CapacitorHttp.swift | 14 +- .../Plugins/CapacitorUrlRequest.swift | 43 +++--- .../Plugins/HttpRequestHandler.swift | 55 ++++--- .../Capacitor/assets/native-bridge.js | 13 +- 12 files changed, 226 insertions(+), 200 deletions(-) diff --git a/android/capacitor/src/main/assets/native-bridge.js b/android/capacitor/src/main/assets/native-bridge.js index e4ab6ef06..a4bd18ea3 100644 --- a/android/capacitor/src/main/assets/native-bridge.js +++ b/android/capacitor/src/main/assets/native-bridge.js @@ -320,7 +320,7 @@ const nativeBridge = (function (exports) { set: function (val) { this._readyState = val; this.dispatchEvent(new Event('readystatechange')); - } + }, }, response: { value: '', @@ -360,12 +360,14 @@ const nativeBridge = (function (exports) { try { this.readyState = 2; // intercept request & pass to the bridge - cap.nativePromise('CapacitorHttp', 'request', { + cap + .nativePromise('CapacitorHttp', 'request', { url: this._url, method: this._method, - data: (body !== null) ? body : undefined, + data: body !== null ? body : undefined, headers: this._headers, - }).then((nativeResponse) => { + }) + .then((nativeResponse) => { // intercept & parse response before returning if (this.readyState == 2) { this.dispatchEvent(new Event('loadstart')); @@ -377,7 +379,8 @@ const nativeBridge = (function (exports) { this.dispatchEvent(new Event('load')); this.dispatchEvent(new Event('loadend')); } - }).catch((error) => { + }) + .catch((error) => { this.dispatchEvent(new Event('loadstart')); this.status = error.status; this.response = error.data; diff --git a/android/capacitor/src/main/java/com/getcapacitor/JSValue.java b/android/capacitor/src/main/java/com/getcapacitor/JSValue.java index 84d0bb8bf..d97ba91bf 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/JSValue.java +++ b/android/capacitor/src/main/java/com/getcapacitor/JSValue.java @@ -62,4 +62,4 @@ private Object toValue(PluginCall call, String name) { if (value != null) return value; return call.getData().opt(name); } -} \ No newline at end of file +} diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java index 1594510c7..ff2925695 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java @@ -2,7 +2,6 @@ import android.Manifest; import android.webkit.JavascriptInterface; - import com.getcapacitor.CapConfig; import com.getcapacitor.JSObject; import com.getcapacitor.Plugin; @@ -15,11 +14,12 @@ @CapacitorPlugin( permissions = { - @Permission(strings = { Manifest.permission.WRITE_EXTERNAL_STORAGE }, alias = "HttpWrite"), - @Permission(strings = { Manifest.permission.READ_EXTERNAL_STORAGE }, alias = "HttpRead") + @Permission(strings = { Manifest.permission.WRITE_EXTERNAL_STORAGE }, alias = "HttpWrite"), + @Permission(strings = { Manifest.permission.READ_EXTERNAL_STORAGE }, alias = "HttpRead") } ) public class CapacitorHttp extends Plugin { + private void http(final PluginCall call, final String httpMethod) { Runnable asyncHttpCall = new Runnable() { @Override diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java index 29c377636..70850e6c9 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java @@ -3,14 +3,10 @@ import android.os.Build; import android.os.LocaleList; import android.text.TextUtils; - import com.getcapacitor.JSArray; import com.getcapacitor.JSObject; import com.getcapacitor.JSValue; import com.getcapacitor.PluginCall; - -import org.json.JSONException; - import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; @@ -25,6 +21,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import org.json.JSONException; public class CapacitorHttpUrlConnection implements ICapacitorHttpUrlConnection { diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java index 0076ebf64..4d0267963 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java @@ -2,16 +2,10 @@ import android.text.TextUtils; import android.util.Base64; - import com.getcapacitor.JSArray; import com.getcapacitor.JSObject; import com.getcapacitor.JSValue; import com.getcapacitor.PluginCall; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -25,8 +19,12 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; public class HttpRequestHandler { + /** * An enum specifying conventional HTTP Response Types * See https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType @@ -119,7 +117,7 @@ public HttpURLConnectionBuilder setUrlParams(JSObject params) throws MalformedUR } public HttpURLConnectionBuilder setUrlParams(JSObject params, boolean shouldEncode) - throws URISyntaxException, MalformedURLException { + throws URISyntaxException, MalformedURLException { String initialQuery = url.getQuery(); String initialQueryBuilderStr = initialQuery == null ? "" : initialQuery; @@ -164,7 +162,13 @@ public HttpURLConnectionBuilder setUrlParams(JSObject params, boolean shouldEnco URI encodedUri = new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), urlQuery, uri.getFragment()); this.url = encodedUri.toURL(); } else { - String unEncodedUrlString = uri.getScheme() + "://" + uri.getAuthority() + uri.getPath() + ((!urlQuery.equals("")) ? "?" + urlQuery : "") + ((uri.getFragment() != null) ? uri.getFragment() : ""); + String unEncodedUrlString = + uri.getScheme() + + "://" + + uri.getAuthority() + + uri.getPath() + + ((!urlQuery.equals("")) ? "?" + urlQuery : "") + + ((uri.getFragment() != null) ? uri.getFragment() : ""); this.url = new URL(unEncodedUrlString); } @@ -196,7 +200,7 @@ private static JSObject buildResponse(CapacitorHttpUrlConnection connection) thr * @throws JSONException Thrown if the JSON is unable to be parsed */ private static JSObject buildResponse(CapacitorHttpUrlConnection connection, ResponseType responseType) - throws IOException, JSONException { + throws IOException, JSONException { int statusCode = connection.getResponseCode(); JSObject output = new JSObject(); @@ -373,14 +377,14 @@ public static JSObject request(PluginCall call, String httpMethod) throws IOExce URL url = new URL(urlString); HttpURLConnectionBuilder connectionBuilder = new HttpURLConnectionBuilder() - .setUrl(url) - .setMethod(method) - .setHeaders(headers) - .setUrlParams(params, shouldEncode) - .setConnectTimeout(connectTimeout) - .setReadTimeout(readTimeout) - .setDisableRedirects(disableRedirects) - .openConnection(); + .setUrl(url) + .setMethod(method) + .setHeaders(headers) + .setUrlParams(params, shouldEncode) + .setConnectTimeout(connectTimeout) + .setReadTimeout(readTimeout) + .setDisableRedirects(disableRedirects) + .openConnection(); CapacitorHttpUrlConnection connection = connectionBuilder.build(); diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java index 9654a77c4..6d7cddd03 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java @@ -12,4 +12,4 @@ interface ICapacitorHttpUrlConnection { String getHeaderField(String name); InputStream getInputStream() throws IOException; -} \ No newline at end of file +} diff --git a/core/native-bridge.ts b/core/native-bridge.ts index 2d1ba0385..e2b427945 100644 --- a/core/native-bridge.ts +++ b/core/native-bridge.ts @@ -12,7 +12,6 @@ import type { WindowCapacitor, } from './src/definitions-internal'; - // For removing exports for iOS/Android, keep let for reassignment // eslint-disable-next-line let dummy = {}; @@ -303,15 +302,22 @@ const initBridge = (w: any): void => { }; // fetch patch - window.fetch = async (resource: RequestInfo | URL, options?: RequestInit) => { + window.fetch = async ( + resource: RequestInfo | URL, + options?: RequestInit, + ) => { try { // intercept request & pass to the bridge - const nativeResponse: HttpResponse = await cap.nativePromise('CapacitorHttp', 'request', { - url: resource, - method: options?.method ? options.method : undefined, - data: options?.body ? options.body : undefined, - headers: options?.headers ? options.headers : undefined, - }); + const nativeResponse: HttpResponse = await cap.nativePromise( + 'CapacitorHttp', + 'request', + { + url: resource, + method: options?.method ? options.method : undefined, + data: options?.body ? options.body : undefined, + headers: options?.headers ? options.headers : undefined, + }, + ); // intercept & parse response before returning const response = new Response(JSON.stringify(nativeResponse.data), { @@ -320,58 +326,58 @@ const initBridge = (w: any): void => { }); return response; - } - catch (error) { + } catch (error) { return Promise.reject(error); } }; // XHR event listeners - const addEventListeners = function() { + const addEventListeners = function () { this.addEventListener('abort', function () { - if(typeof this.onabort === 'function') this.onabort(); + if (typeof this.onabort === 'function') this.onabort(); }); - + this.addEventListener('error', function () { - if(typeof this.onerror === 'function') this.onerror(); + if (typeof this.onerror === 'function') this.onerror(); }); - + this.addEventListener('load', function () { - if(typeof this.onload === 'function') this.onload(); + if (typeof this.onload === 'function') this.onload(); }); - + this.addEventListener('loadend', function () { - if(typeof this.onloadend === 'function') this.onloadend(); + if (typeof this.onloadend === 'function') this.onloadend(); }); - + this.addEventListener('loadstart', function () { - if(typeof this.onloadstart === 'function') this.onloadstart(); + if (typeof this.onloadstart === 'function') this.onloadstart(); }); - + this.addEventListener('readystatechange', function () { - if(typeof this.onreadystatechange === 'function') this.onreadystatechange(); + if (typeof this.onreadystatechange === 'function') + this.onreadystatechange(); }); - + this.addEventListener('timeout', function () { - if(typeof this.ontimeout === 'function') this.ontimeout(); + if (typeof this.ontimeout === 'function') this.ontimeout(); }); - } + }; // XHR patch abort - window.XMLHttpRequest.prototype.abort = function() { + window.XMLHttpRequest.prototype.abort = function () { Object.defineProperties(this, { _headers: { value: {}, writable: true, }, readyState: { - get: function() { + get: function () { return this._readyState ?? 0; }, set: function (val: number) { this._readyState = val; this.dispatchEvent(new Event('readystatechange')); - } + }, }, response: { value: '', @@ -393,10 +399,13 @@ const initBridge = (w: any): void => { this.readyState = 0; this.dispatchEvent(new Event('abort')); this.dispatchEvent(new Event('loadend')); - } + }; // XHR patch open - window.XMLHttpRequest.prototype.open = function (method: string, url: string) { + window.XMLHttpRequest.prototype.open = function ( + method: string, + url: string, + ) { this.abort(); addEventListeners.call(this); this._method = method; @@ -405,47 +414,52 @@ const initBridge = (w: any): void => { }; // XHR patch set request header - window.XMLHttpRequest.prototype.setRequestHeader = function (header: string, value: string) { + window.XMLHttpRequest.prototype.setRequestHeader = function ( + header: string, + value: string, + ) { this._headers[header] = value; }; // XHR patch send window.XMLHttpRequest.prototype.send = function ( - body?: Document | XMLHttpRequestBodyInit + body?: Document | XMLHttpRequestBodyInit, ) { try { this.readyState = 2; - + // intercept request & pass to the bridge - cap.nativePromise('CapacitorHttp', 'request', { - url: this._url, - method: this._method, - data: (body !== null) ? body : undefined, - headers: this._headers, - }).then((nativeResponse: any) => { - // intercept & parse response before returning - if (this.readyState == 2) { + cap + .nativePromise('CapacitorHttp', 'request', { + url: this._url, + method: this._method, + data: body !== null ? body : undefined, + headers: this._headers, + }) + .then((nativeResponse: any) => { + // intercept & parse response before returning + if (this.readyState == 2) { + this.dispatchEvent(new Event('loadstart')); + this.status = nativeResponse.status; + this.response = nativeResponse.data; + this.responseText = JSON.stringify(nativeResponse.data); + this.responseURL = nativeResponse.url; + this.readyState = 4; + this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('loadend')); + } + }) + .catch((error: any) => { this.dispatchEvent(new Event('loadstart')); - this.status = nativeResponse.status; - this.response = nativeResponse.data; - this.responseText = JSON.stringify(nativeResponse.data); - this.responseURL = nativeResponse.url; + this.status = error.status; + this.response = error.data; + this.responseText = JSON.stringify(error.data); + this.responseURL = error.url; this.readyState = 4; - this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('error')); this.dispatchEvent(new Event('loadend')); - } - }).catch((error: any) => { - this.dispatchEvent(new Event('loadstart')); - this.status = error.status; - this.response = error.data; - this.responseText = JSON.stringify(error.data); - this.responseURL = error.url; - this.readyState = 4; - this.dispatchEvent(new Event('error')); - this.dispatchEvent(new Event('loadend')); - }); - } - catch (error) { + }); + } catch (error) { this.dispatchEvent(new Event('loadstart')); this.status = 500; this.response = error; @@ -455,7 +469,7 @@ const initBridge = (w: any): void => { this.dispatchEvent(new Event('error')); this.dispatchEvent(new Event('loadend')); } - } + }; } // patch window.console on iOS and store original console fns diff --git a/core/src/core-plugins.ts b/core/src/core-plugins.ts index d4ddfe644..960a8e658 100644 --- a/core/src/core-plugins.ts +++ b/core/src/core-plugins.ts @@ -24,7 +24,12 @@ export interface CapacitorHttpPlugin { delete(options: HttpOptions): Promise; } -export type HttpResponseType = 'arraybuffer' | 'blob' | 'json' | 'text' | 'document'; +export type HttpResponseType = + | 'arraybuffer' + | 'blob' + | 'json' + | 'text' + | 'document'; export interface HttpOptions { url: string; @@ -184,57 +189,60 @@ export const buildRequestInit = ( }; // WEB IMPLEMENTATION -export class CapacitorHttpPluginWeb extends WebPlugin implements CapacitorHttpPlugin { +export class CapacitorHttpPluginWeb + extends WebPlugin + implements CapacitorHttpPlugin +{ /** * Perform an Http request given a set of options * @param options Options to build the HTTP request */ async request(options: HttpOptions): Promise { const requestInit = buildRequestInit(options, options.webFetchExtra); - const urlParams = buildUrlParams( - options.params, - options.shouldEncodeUrlParams, - ); - const url = urlParams ? `${options.url}?${urlParams}` : options.url; + const urlParams = buildUrlParams( + options.params, + options.shouldEncodeUrlParams, + ); + const url = urlParams ? `${options.url}?${urlParams}` : options.url; - const response = await fetch(url, requestInit); - const contentType = response.headers.get('content-type') || ''; + const response = await fetch(url, requestInit); + const contentType = response.headers.get('content-type') || ''; - // Default to 'text' responseType so no parsing happens - let { responseType = 'text' } = response.ok ? options : {}; + // Default to 'text' responseType so no parsing happens + let { responseType = 'text' } = response.ok ? options : {}; - // If the response content-type is json, force the response to be json - if (contentType.includes('application/json')) { - responseType = 'json'; - } + // If the response content-type is json, force the response to be json + if (contentType.includes('application/json')) { + responseType = 'json'; + } - let data: any; - switch (responseType) { - case 'arraybuffer': - case 'blob': - //TODO: Add Blob Support - break; - case 'json': - data = await response.json(); - break; - case 'document': - case 'text': - default: - data = await response.text(); - } + let data: any; + switch (responseType) { + case 'arraybuffer': + case 'blob': + //TODO: Add Blob Support + break; + case 'json': + data = await response.json(); + break; + case 'document': + case 'text': + default: + data = await response.text(); + } - // Convert fetch headers to Capacitor HttpHeaders - const headers = {} as HttpHeaders; - response.headers.forEach((value: string, key: string) => { - headers[key] = value; - }); - - return { - data, - headers, - status: response.status, - url: response.url, - }; + // Convert fetch headers to Capacitor HttpHeaders + const headers = {} as HttpHeaders; + response.headers.forEach((value: string, key: string) => { + headers[key] = value; + }); + + return { + data, + headers, + status: response.status, + url: response.url, + }; } /** @@ -244,7 +252,7 @@ export class CapacitorHttpPluginWeb extends WebPlugin implements CapacitorHttpPl async get(options: HttpOptions): Promise { return this.request({ ...options, method: 'GET' }); } - + /** * Perform an Http POST request given a set of options * @param options Options to build the HTTP request @@ -252,7 +260,7 @@ export class CapacitorHttpPluginWeb extends WebPlugin implements CapacitorHttpPl async post(options: HttpOptions): Promise { return this.request({ ...options, method: 'POST' }); } - + /** * Perform an Http PUT request given a set of options * @param options Options to build the HTTP request @@ -260,7 +268,7 @@ export class CapacitorHttpPluginWeb extends WebPlugin implements CapacitorHttpPl async put(options: HttpOptions): Promise { return this.request({ ...options, method: 'PUT' }); } - + /** * Perform an Http PATCH request given a set of options * @param options Options to build the HTTP request @@ -268,7 +276,7 @@ export class CapacitorHttpPluginWeb extends WebPlugin implements CapacitorHttpPl async patch(options: HttpOptions): Promise { return this.request({ ...options, method: 'PATCH' }); } - + /** * Perform an Http DELETE request given a set of options * @param options Options to build the HTTP request @@ -276,7 +284,6 @@ export class CapacitorHttpPluginWeb extends WebPlugin implements CapacitorHttpPl async delete(options: HttpOptions): Promise { return this.request({ ...options, method: 'DELETE' }); } - } export const CapacitorHttp = registerPlugin( @@ -286,4 +293,4 @@ export const CapacitorHttp = registerPlugin( }, ); -/******** END HTTP PLUGIN ********/ \ No newline at end of file +/******** END HTTP PLUGIN ********/ diff --git a/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift b/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift index 6043bad65..11ea449eb 100644 --- a/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift +++ b/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift @@ -7,34 +7,34 @@ public class CAPHttpPlugin: CAPPlugin { guard let u = call.getString("url") else { return call.reject("Must provide a URL"); } guard let _ = httpMethod ?? call.getString("method") else { return call.reject("Must provide an HTTP Method"); } guard var _ = URL(string: u) else { return call.reject("Invalid URL"); } - + do { try HttpRequestHandler.request(call, httpMethod) } catch let e { call.reject(e.localizedDescription) } } - + @objc func request(_ call: CAPPluginCall) { http(call, nil) } - + @objc func get(_ call: CAPPluginCall) { http(call, "GET") } - + @objc func post(_ call: CAPPluginCall) { http(call, "POST") } - + @objc func put(_ call: CAPPluginCall) { http(call, "PUT") } - + @objc func patch(_ call: CAPPluginCall) { http(call, "PATCH") } - + @objc func del(_ call: CAPPluginCall) { http(call, "DELETE") } diff --git a/ios/Capacitor/Capacitor/Plugins/CapacitorUrlRequest.swift b/ios/Capacitor/Capacitor/Plugins/CapacitorUrlRequest.swift index 32cc6b733..745e11698 100644 --- a/ios/Capacitor/Capacitor/Plugins/CapacitorUrlRequest.swift +++ b/ios/Capacitor/Capacitor/Plugins/CapacitorUrlRequest.swift @@ -1,9 +1,9 @@ import Foundation public class CapacitorUrlRequest: NSObject, URLSessionTaskDelegate { - private var request: URLRequest; - private var headers: [String:String]; - + private var request: URLRequest + private var headers: [String: String] + enum CapacitorUrlRequestError: Error { case serializationError(String?) } @@ -21,7 +21,7 @@ public class CapacitorUrlRequest: NSObject, URLSessionTaskDelegate { request.addValue(headers["Accept-Language"]!, forHTTPHeaderField: "Accept-Language") } } - + private func getRequestDataAsJson(_ data: JSValue) throws -> Data? { // We need to check if the JSON is valid before attempting to serialize, as JSONSerialization.data will not throw an exception that can be caught, and will cause the application to crash if it fails. if JSONSerialization.isValidJSONObject(data) { @@ -30,54 +30,53 @@ public class CapacitorUrlRequest: NSObject, URLSessionTaskDelegate { throw CapacitorUrlRequest.CapacitorUrlRequestError.serializationError("[ data ] argument for request of content-type [ application/json ] must be serializable to JSON") } } - + private func getRequestDataAsFormUrlEncoded(_ data: JSValue) throws -> Data? { guard var components = URLComponents(url: request.url!, resolvingAgainstBaseURL: false) else { return nil } components.queryItems = [] - + guard let obj = data as? JSObject else { // Throw, other data types explicitly not supported throw CapacitorUrlRequestError.serializationError("[ data ] argument for request with content-type [ multipart/form-data ] may only be a plain javascript object") } - + obj.keys.forEach { (key: String) in components.queryItems?.append(URLQueryItem(name: key, value: "\(obj[key] ?? "")")) } - - + if components.query != nil { return Data(components.query!.utf8) } return nil } - + private func getRequestDataAsMultipartFormData(_ data: JSValue) throws -> Data { guard let obj = data as? JSObject else { // Throw, other data types explicitly not supported. throw CapacitorUrlRequestError.serializationError("[ data ] argument for request with content-type [ application/x-www-form-urlencoded ] may only be a plain javascript object") } - + let strings: [String: String] = obj.compactMapValues { any in any as? String } - + var data = Data() let boundary = UUID().uuidString let contentType = "multipart/form-data; boundary=\(boundary)" request.setValue(contentType, forHTTPHeaderField: "Content-Type") headers["Content-Type"] = contentType - + strings.forEach { key, value in data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) data.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) data.append(value.data(using: .utf8)!) } data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) - + return data } - + private func getRequestDataAsString(_ data: JSValue) throws -> Data { guard let stringData = data as? String else { throw CapacitorUrlRequestError.serializationError("[ data ] argument could not be parsed as string") @@ -86,14 +85,14 @@ public class CapacitorUrlRequest: NSObject, URLSessionTaskDelegate { } func getRequestHeader(_ index: String) -> Any? { - var normalized = [:] as [String:Any] + var normalized = [:] as [String: Any] self.headers.keys.forEach { (key: String) in normalized[key.lowercased()] = self.headers[key] } return normalized[index.lowercased()] } - + func getRequestData(_ body: JSValue, _ contentType: String) throws -> Data? { // If data can be parsed directly as a string, return that without processing. if let strVal = try? getRequestDataAsString(body) { @@ -116,7 +115,7 @@ public class CapacitorUrlRequest: NSObject, URLSessionTaskDelegate { self.headers[key] = value } } - + public func setRequestBody(_ body: JSValue) throws { let contentType = self.getRequestHeader("Content-Type") as? String @@ -130,20 +129,20 @@ public class CapacitorUrlRequest: NSObject, URLSessionTaskDelegate { } public func setTimeout(_ timeout: TimeInterval) { - request.timeoutInterval = timeout; + request.timeoutInterval = timeout } public func getUrlRequest() -> URLRequest { - return request; + return request } - + public func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) } public func getUrlSession(_ call: CAPPluginCall) -> URLSession { let disableRedirects = call.getBool("disableRedirects") ?? false - if (!disableRedirects) { + if !disableRedirects { return URLSession.shared } return URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: nil) diff --git a/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift b/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift index 68e95d413..c26afb031 100644 --- a/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift +++ b/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift @@ -1,7 +1,7 @@ import Foundation /// See https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType -fileprivate enum ResponseType: String { +private enum ResponseType: String { case arrayBuffer = "arraybuffer" case blob = "blob" case document = "document" @@ -30,18 +30,18 @@ fileprivate enum ResponseType: String { /// - data: The JSON Data to parse /// - Returns: The parsed value or an error func tryParseJson(_ data: Data) -> Any { - do { - return try JSONSerialization.jsonObject(with: data, options: .mutableContainers) - } catch { - return error.localizedDescription - } + do { + return try JSONSerialization.jsonObject(with: data, options: .mutableContainers) + } catch { + return error.localizedDescription + } } class HttpRequestHandler { private class CapacitorHttpRequestBuilder { private var url: URL? private var method: String? - private var params: [String:String]? + private var params: [String: String]? private var request: CapacitorUrlRequest? /// Set the URL of the HttpRequest @@ -58,12 +58,12 @@ class HttpRequestHandler { } public func setMethod(_ method: String) -> CapacitorHttpRequestBuilder { - self.method = method; + self.method = method return self } - public func setUrlParams(_ params: [String:Any]) -> CapacitorHttpRequestBuilder { - if (params.count != 0) { + public func setUrlParams(_ params: [String: Any]) -> CapacitorHttpRequestBuilder { + if params.count != 0 { var cmps = URLComponents(url: url!, resolvingAgainstBaseURL: true) if cmps?.queryItems == nil { cmps?.queryItems = [] @@ -96,8 +96,8 @@ class HttpRequestHandler { } } - private static func buildResponse(_ data: Data?, _ response: HTTPURLResponse, responseType: ResponseType = .default) -> [String:Any] { - var output = [:] as [String:Any] + private static func buildResponse(_ data: Data?, _ response: HTTPURLResponse, responseType: ResponseType = .default) -> [String: Any] { + var output = [:] as [String: Any] output["status"] = response.statusCode output["headers"] = response.allHeaderFields @@ -108,41 +108,40 @@ class HttpRequestHandler { return output } - let contentType = (response.allHeaderFields["Content-Type"] as? String ?? "application/default").lowercased(); + let contentType = (response.allHeaderFields["Content-Type"] as? String ?? "application/default").lowercased() - if (contentType.contains("application/json") || responseType == .json) { - output["data"] = tryParseJson(data); - } else if (responseType == .arrayBuffer || responseType == .blob) { - output["data"] = data.base64EncodedString(); - } else if (responseType == .document || responseType == .text || responseType == .default) { + if contentType.contains("application/json") || responseType == .json { + output["data"] = tryParseJson(data) + } else if responseType == .arrayBuffer || responseType == .blob { + output["data"] = data.base64EncodedString() + } else if responseType == .document || responseType == .text || responseType == .default { output["data"] = String(data: data, encoding: .utf8) } return output } - public static func request(_ call: CAPPluginCall, _ httpMethod: String?) throws { guard let urlString = call.getString("url") else { throw URLError(.badURL) } guard let method = httpMethod ?? call.getString("method") else { throw URLError(.dataNotAllowed) } let headers = (call.getObject("headers") ?? [:]) as! [String: String] let params = (call.getObject("params") ?? [:]) as! [String: Any] - let responseType = call.getString("responseType") ?? "text"; - let connectTimeout = call.getDouble("connectTimeout"); - let readTimeout = call.getDouble("readTimeout"); + let responseType = call.getString("responseType") ?? "text" + let connectTimeout = call.getDouble("connectTimeout") + let readTimeout = call.getDouble("readTimeout") let request = try! CapacitorHttpRequestBuilder() .setUrl(urlString) .setMethod(method) .setUrlParams(params) .openConnection() - .build(); + .build() request.setRequestHeaders(headers) // Timeouts in iOS are in seconds. So read the value in millis and divide by 1000 - let timeout = (connectTimeout ?? readTimeout ?? 600000.0) / 1000.0; + let timeout = (connectTimeout ?? readTimeout ?? 600000.0) / 1000.0 request.setTimeout(timeout) if let data = call.options["data"] as? JSValue { @@ -156,10 +155,10 @@ class HttpRequestHandler { } } - let urlRequest = request.getUrlRequest(); - let urlSession = request.getUrlSession(call); + let urlRequest = request.getUrlRequest() + let urlSession = request.getUrlSession(call) let task = urlSession.dataTask(with: urlRequest) { (data, response, error) in - urlSession.invalidateAndCancel(); + urlSession.invalidateAndCancel() if error != nil { return } @@ -168,6 +167,6 @@ class HttpRequestHandler { call.resolve(self.buildResponse(data, response as! HTTPURLResponse, responseType: type)) } - task.resume(); + task.resume() } } diff --git a/ios/Capacitor/Capacitor/assets/native-bridge.js b/ios/Capacitor/Capacitor/assets/native-bridge.js index e4ab6ef06..a4bd18ea3 100644 --- a/ios/Capacitor/Capacitor/assets/native-bridge.js +++ b/ios/Capacitor/Capacitor/assets/native-bridge.js @@ -320,7 +320,7 @@ const nativeBridge = (function (exports) { set: function (val) { this._readyState = val; this.dispatchEvent(new Event('readystatechange')); - } + }, }, response: { value: '', @@ -360,12 +360,14 @@ const nativeBridge = (function (exports) { try { this.readyState = 2; // intercept request & pass to the bridge - cap.nativePromise('CapacitorHttp', 'request', { + cap + .nativePromise('CapacitorHttp', 'request', { url: this._url, method: this._method, - data: (body !== null) ? body : undefined, + data: body !== null ? body : undefined, headers: this._headers, - }).then((nativeResponse) => { + }) + .then((nativeResponse) => { // intercept & parse response before returning if (this.readyState == 2) { this.dispatchEvent(new Event('loadstart')); @@ -377,7 +379,8 @@ const nativeBridge = (function (exports) { this.dispatchEvent(new Event('load')); this.dispatchEvent(new Event('loadend')); } - }).catch((error) => { + }) + .catch((error) => { this.dispatchEvent(new Event('loadstart')); this.status = error.status; this.response = error.data; From f7eb54f9c5c4a4c782f44881be32332e7a62afde Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Tue, 30 Aug 2022 08:24:09 -0700 Subject: [PATCH 03/15] feat: merge cookies and http --- LICENSE | 36 ++-- android/LICENSE | 36 ++-- .../src/main/assets/native-bridge.js | 42 ++++- .../main/java/com/getcapacitor/Bridge.java | 1 + .../plugin/CapacitorCookieManager.java | 172 ++++++++++++++++++ .../getcapacitor/plugin/CapacitorCookies.java | 122 +++++++++++++ cli/LICENSE | 36 ++-- core/LICENSE | 36 ++-- core/native-bridge.ts | 51 +++++- core/src/core-plugins.ts | 99 ++++++++++ core/src/definitions-internal.ts | 2 + core/src/index.ts | 5 +- .../Capacitor.xcodeproj/project.pbxproj | 8 + .../Plugins/CapacitorCookieManager.swift | 66 +++++++ .../Capacitor/Plugins/CapacitorCookies.swift | 52 ++++++ .../Capacitor/Plugins/DefaultPlugins.m | 7 + .../Capacitor/WebViewDelegationHandler.swift | 18 ++ .../Capacitor/assets/native-bridge.js | 42 ++++- ios/LICENSE | 36 ++-- 19 files changed, 765 insertions(+), 102 deletions(-) create mode 100644 android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookieManager.java create mode 100644 android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java create mode 100644 ios/Capacitor/Capacitor/Plugins/CapacitorCookieManager.swift create mode 100644 ios/Capacitor/Capacitor/Plugins/CapacitorCookies.swift diff --git a/LICENSE b/LICENSE index 623c70a83..c3e903bdd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,23 +1,21 @@ -Copyright 2015-present Drifty Co. -http://drifty.com/ - MIT License -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Copyright (c) 2017-present Drifty Co. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/android/LICENSE b/android/LICENSE index 941061474..c3e903bdd 100644 --- a/android/LICENSE +++ b/android/LICENSE @@ -1,23 +1,21 @@ -Copyright 2017-present Ionic -https://ionic.io - MIT License -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Copyright (c) 2017-present Drifty Co. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/android/capacitor/src/main/assets/native-bridge.js b/android/capacitor/src/main/assets/native-bridge.js index a4bd18ea3..8cfa2efa7 100644 --- a/android/capacitor/src/main/assets/native-bridge.js +++ b/android/capacitor/src/main/assets/native-bridge.js @@ -241,10 +241,48 @@ const nativeBridge = (function (exports) { } return String(msg); }; + /** + * Safely web decode a string value (inspired by js-cookie) + * @param str The string value to decode + */ + const decode = (str) => str.replace(/(%[\dA-F]{2})+/gi, decodeURIComponent); const platform = getPlatformId(win); - // TODO: Check cap config for opt-out - // patch fetch / XHR on Android/iOS if (platform == 'android' || platform == 'ios') { + // patch document.cookie on Android/iOS + win.CapacitorCookiesDescriptor = + Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') || + Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie'); + Object.defineProperty(document, 'cookie', { + get: function () { + if (platform === 'ios') { + // Use prompt to synchronously get cookies. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + const payload = { + type: 'CapacitorCookies', + }; + const res = prompt(JSON.stringify(payload)); + return res; + } + else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') { + return win.CapacitorCookiesAndroidInterface.getCookies(); + } + }, + set: function (val) { + const cookiePairs = val.split(';'); + for (const cookiePair of cookiePairs) { + const cookieKey = cookiePair.split('=')[0]; + const cookieValue = cookiePair.split('=')[1]; + if (null == cookieValue) { + continue; + } + cap.toNative('CapacitorCookies', 'setCookie', { + key: cookieKey, + value: decode(cookieValue), + }); + } + }, + }); + // patch fetch / XHR on Android/iOS // store original fetch & XHR functions win.CapacitorWebFetch = window.fetch; win.CapacitorWebXMLHttpRequest = { diff --git a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java index 850536886..1abadffd8 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java +++ b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java @@ -556,6 +556,7 @@ private void initWebView() { * Register our core Plugin APIs */ private void registerAllPlugins() { + this.registerPlugin(com.getcapacitor.plugin.CapacitorCookies.class); this.registerPlugin(com.getcapacitor.plugin.WebView.class); this.registerPlugin(com.getcapacitor.plugin.CapacitorHttp.class); diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookieManager.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookieManager.java new file mode 100644 index 000000000..9785318c0 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookieManager.java @@ -0,0 +1,172 @@ +package com.getcapacitor.plugin; + +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.CookieStore; +import java.net.HttpCookie; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class CapacitorCookieManager extends CookieManager { + + private final android.webkit.CookieManager webkitCookieManager; + + /** + * Create a new cookie manager with the default cookie store and policy + */ + public CapacitorCookieManager() { + this(null, null); + } + + /** + * Create a new cookie manager with specified cookie store and cookie policy. + * @param store a {@code CookieStore} to be used by CookieManager. if {@code null}, cookie + * manager will use a default one, which is an in-memory CookieStore implementation. + * @param policy a {@code CookiePolicy} instance to be used by cookie manager as policy + * callback. if {@code null}, ACCEPT_ORIGINAL_SERVER will be used. + */ + public CapacitorCookieManager(CookieStore store, CookiePolicy policy) { + super(store, policy); + webkitCookieManager = android.webkit.CookieManager.getInstance(); + } + + /** + * Gets the cookies for the given URL. + * @param url the URL for which the cookies are requested + * @return value the cookies as a string, using the format of the 'Cookie' HTTP request header + */ + public String getCookieString(String url) { + return webkitCookieManager.getCookie(url); + } + + /** + * Gets a cookie value for the given URL and key. + * @param url the URL for which the cookies are requested + * @param key the key of the cookie to search for + * @return the {@code HttpCookie} value of the cookie at the key, + * otherwise it will return a new empty {@code HttpCookie} + */ + public HttpCookie getCookie(String url, String key) { + HttpCookie[] cookies = getCookies(url); + for (HttpCookie cookie : cookies) { + if (cookie.getName().equals(key)) { + return cookie; + } + } + + return null; + } + + /** + * Gets an array of {@code HttpCookie} given a URL. + * @param url the URL for which the cookies are requested + * @return an {@code HttpCookie} array of non-expired cookies + */ + public HttpCookie[] getCookies(String url) { + try { + ArrayList cookieList = new ArrayList<>(); + String cookieString = getCookieString(url); + if (cookieString != null) { + String[] singleCookie = cookieString.split(";"); + for (String c : singleCookie) { + HttpCookie parsed = HttpCookie.parse(c).get(0); + parsed.setValue(parsed.getValue()); + cookieList.add(parsed); + } + } + HttpCookie[] cookies = new HttpCookie[cookieList.size()]; + return cookieList.toArray(cookies); + } catch (Exception ex) { + return new HttpCookie[0]; + } + } + + /** + * Sets a cookie for the given URL. Any existing cookie with the same host, path and name will + * be replaced with the new cookie. The cookie being set will be ignored if it is expired. + * @param url the URL for which the cookie is to be set + * @param value the cookie as a string, using the format of the 'Set-Cookie' HTTP response header + */ + public void setCookie(String url, String value) { + webkitCookieManager.setCookie(url, value); + flush(); + } + + /** + * Sets a cookie for the given URL. Any existing cookie with the same host, path and name will + * be replaced with the new cookie. The cookie being set will be ignored if it is expired. + * @param url the URL for which the cookie is to be set + * @param key the {@code HttpCookie} name to use for lookup + * @param value the value of the {@code HttpCookie} given a key + */ + public void setCookie(String url, String key, String value) { + String cookieValue = key + "=" + value; + setCookie(url, cookieValue); + } + + /** + * Removes all cookies. This method is asynchronous. + */ + public void removeAllCookies() { + webkitCookieManager.removeAllCookies(null); + flush(); + } + + /** + * Ensures all cookies currently accessible through the getCookie API are written to persistent + * storage. This call will block the caller until it is done and may perform I/O. + */ + public void flush() { + webkitCookieManager.flush(); + } + + @Override + public void put(URI uri, Map> responseHeaders) { + // make sure our args are valid + if ((uri == null) || (responseHeaders == null)) return; + + // save our url once + String url = uri.toString(); + + // go over the headers + for (String headerKey : responseHeaders.keySet()) { + // ignore headers which aren't cookie related + if ((headerKey == null) || !(headerKey.equalsIgnoreCase("Set-Cookie2") || headerKey.equalsIgnoreCase("Set-Cookie"))) continue; + + // process each of the headers + for (String headerValue : Objects.requireNonNull(responseHeaders.get(headerKey))) { + setCookie(url, headerValue); + } + } + } + + @Override + public Map> get(URI uri, Map> requestHeaders) { + // make sure our args are valid + if ((uri == null) || (requestHeaders == null)) throw new IllegalArgumentException("Argument is null"); + + // save our url once + String url = uri.toString(); + + // prepare our response + Map> res = new HashMap<>(); + + // get the cookie + String cookie = getCookieString(url); + + // return it + if (cookie != null) res.put("Cookie", Collections.singletonList(cookie)); + return res; + } + + @Override + public CookieStore getCookieStore() { + // we don't want anyone to work with this cookie store directly + throw new UnsupportedOperationException(); + } +} diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java new file mode 100644 index 000000000..e980a339c --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java @@ -0,0 +1,122 @@ +package com.getcapacitor.plugin; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.util.Log; +import android.webkit.JavascriptInterface; +import androidx.annotation.Nullable; +import com.getcapacitor.CapConfig; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import java.net.CookieHandler; +import java.net.HttpCookie; +import java.net.URI; + +@CapacitorPlugin +public class CapacitorCookies extends Plugin { + + CapacitorCookieManager cookieManager; + + @Override + public void load() { + this.bridge.getWebView().addJavascriptInterface(this, "CapacitorCookiesAndroidInterface"); + this.cookieManager = new CapacitorCookieManager(null, java.net.CookiePolicy.ACCEPT_ALL); + CookieHandler.setDefault(cookieManager); + super.load(); + } + + /** + * Helper function for getting the serverUrl from the Capacitor Config. Returns an empty + * string if it is invalid and will auto-reject through {@code call} + * @param call the {@code PluginCall} context + * @return the string of the server specified in the Capacitor config + */ + private String getServerUrl(@Nullable PluginCall call) { + String url = (call == null) ? this.bridge.getServerUrl() : call.getString("url", this.bridge.getServerUrl()); + + if (url == null || url.isEmpty()) { + url = this.bridge.getLocalUrl(); + } + + URI uri = getUri(url); + if (uri == null) { + if (call != null) { + call.reject("Invalid URL. Check that \"server\" is passed in correctly"); + } + + return ""; + } + + return url; + } + + /** + * Try to parse a url string and if it can't be parsed, return null + * @param url the url string to try to parse + * @return a parsed URI + */ + private URI getUri(String url) { + try { + return new URI(url); + } catch (Exception ex) { + return null; + } + } + + @JavascriptInterface + public String getCookies() { + try { + String url = getServerUrl(null); + if (!url.isEmpty()) { + return cookieManager.getCookieString(url); + } + } catch (Exception e) { + e.printStackTrace(); + } + + return ""; + } + + @PluginMethod + public void setCookie(PluginCall call) { + String key = call.getString("key"); + String value = call.getString("value"); + String url = getServerUrl(call); + + if (!url.isEmpty()) { + cookieManager.setCookie(url, key, value); + call.resolve(); + } + } + + @PluginMethod + public void deleteCookie(PluginCall call) { + String key = call.getString("key"); + String url = getServerUrl(call); + if (!url.isEmpty()) { + cookieManager.setCookie(url, key + "=; Expires=Wed, 31 Dec 2000 23:59:59 GMT"); + call.resolve(); + } + } + + @PluginMethod + public void clearCookies(PluginCall call) { + String url = getServerUrl(call); + if (!url.isEmpty()) { + HttpCookie[] cookies = cookieManager.getCookies(url); + for (HttpCookie cookie : cookies) { + cookieManager.setCookie(url, cookie.getName() + "=; Expires=Wed, 31 Dec 2000 23:59:59 GMT"); + } + call.resolve(); + } + } + + @PluginMethod + public void clearAllCookies(PluginCall call) { + cookieManager.removeAllCookies(); + call.resolve(); + } +} diff --git a/cli/LICENSE b/cli/LICENSE index 941061474..c3e903bdd 100644 --- a/cli/LICENSE +++ b/cli/LICENSE @@ -1,23 +1,21 @@ -Copyright 2017-present Ionic -https://ionic.io - MIT License -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Copyright (c) 2017-present Drifty Co. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/core/LICENSE b/core/LICENSE index 941061474..c3e903bdd 100644 --- a/core/LICENSE +++ b/core/LICENSE @@ -1,23 +1,21 @@ -Copyright 2017-present Ionic -https://ionic.io - MIT License -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Copyright (c) 2017-present Drifty Co. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/core/native-bridge.ts b/core/native-bridge.ts index e2b427945..6df0e07d4 100644 --- a/core/native-bridge.ts +++ b/core/native-bridge.ts @@ -287,11 +287,58 @@ const initBridge = (w: any): void => { return String(msg); }; + /** + * Safely web decode a string value (inspired by js-cookie) + * @param str The string value to decode + */ + const decode = (str: string): string => + str.replace(/(%[\dA-F]{2})+/gi, decodeURIComponent); + const platform = getPlatformId(win); - // TODO: Check cap config for opt-out - // patch fetch / XHR on Android/iOS if (platform == 'android' || platform == 'ios') { + // patch document.cookie on Android/iOS + win.CapacitorCookiesDescriptor = + Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') || + Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie'); + + Object.defineProperty(document, 'cookie', { + get: function () { + if (platform === 'ios') { + // Use prompt to synchronously get cookies. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + + const payload = { + type: 'CapacitorCookies', + }; + + const res = prompt(JSON.stringify(payload)); + return res; + } else if ( + typeof win.CapacitorCookiesAndroidInterface !== 'undefined' + ) { + return win.CapacitorCookiesAndroidInterface.getCookies(); + } + }, + set: function (val) { + const cookiePairs = val.split(';'); + for (const cookiePair of cookiePairs) { + const cookieKey = cookiePair.split('=')[0]; + const cookieValue = cookiePair.split('=')[1]; + + if (null == cookieValue) { + continue; + } + + cap.toNative('CapacitorCookies', 'setCookie', { + key: cookieKey, + value: decode(cookieValue), + }); + } + }, + }); + + // patch fetch / XHR on Android/iOS // store original fetch & XHR functions win.CapacitorWebFetch = window.fetch; win.CapacitorWebXMLHttpRequest = { diff --git a/core/src/core-plugins.ts b/core/src/core-plugins.ts index 960a8e658..371994a3e 100644 --- a/core/src/core-plugins.ts +++ b/core/src/core-plugins.ts @@ -2,6 +2,7 @@ import type { Plugin } from './definitions'; import { registerPlugin } from './global'; import { WebPlugin } from './web-plugin'; +/******** WEB VIEW PLUGIN ********/ export interface WebViewPlugin extends Plugin { setServerBasePath(options: WebViewPath): Promise; getServerBasePath(): Promise; @@ -13,6 +14,104 @@ export interface WebViewPath { } export const WebView = /*#__PURE__*/ registerPlugin('WebView'); +/******** END WEB VIEW PLUGIN ********/ + +/******** COOKIES PLUGIN ********/ +/** + * Safely web encode a string value (inspired by js-cookie) + * @param str The string value to encode + */ +const encode = (str: string) => + encodeURIComponent(str) + .replace(/%(2[346B]|5E|60|7C)/g, decodeURIComponent) + .replace(/[()]/g, escape); + +export interface CapacitorCookiesPlugin { + setCookie(options: SetCookieOptions): Promise; + deleteCookie(options: DeleteCookieOptions): Promise; + clearCookies(options: ClearCookieOptions): Promise; + clearAllCookies(): Promise; +} + +interface HttpCookie { + url?: string; + key: string; + value: string; +} + +interface HttpCookieExtras { + path?: string; + expires?: string; +} + +export type SetCookieOptions = HttpCookie & HttpCookieExtras; +export type DeleteCookieOptions = Omit; +export type ClearCookieOptions = Omit; + +export class CapacitorCookiesPluginWeb + extends WebPlugin + implements CapacitorCookiesPlugin +{ + async setCookie(options: SetCookieOptions): Promise { + try { + // Safely Encoded Key/Value + const encodedKey = encode(options.key); + const encodedValue = encode(options.value); + + // Clean & sanitize options + const expires = `; expires=${(options.expires || '').replace( + 'expires=', + '', + )}`; // Default is "; expires=" + + const path = (options.path || '/').replace('path=', ''); // Default is "path=/" + + document.cookie = `${encodedKey}=${ + encodedValue || '' + }${expires}; path=${path}`; + } catch (error) { + return Promise.reject(error); + } + } + + async deleteCookie(options: DeleteCookieOptions): Promise { + try { + document.cookie = `${options.key}=; Max-Age=0`; + } catch (error) { + return Promise.reject(error); + } + } + + async clearCookies(): Promise { + try { + const cookies = document.cookie.split(';') || []; + for (const cookie of cookies) { + document.cookie = cookie + .replace(/^ +/, '') + .replace(/=.*/, `=;expires=${new Date().toUTCString()};path=/`); + } + } catch (error) { + return Promise.reject(error); + } + } + + async clearAllCookies(): Promise { + try { + await this.clearCookies(); + } catch (error) { + return Promise.reject(error); + } + } +} + +export const CapacitorCookies = registerPlugin( + 'CapacitorCookies', + { + web: () => new CapacitorCookiesPluginWeb(), + }, +); + +/******** END COOKIES PLUGIN ********/ /******** HTTP PLUGIN ********/ export interface CapacitorHttpPlugin { diff --git a/core/src/definitions-internal.ts b/core/src/definitions-internal.ts index 03076425e..721a694e6 100644 --- a/core/src/definitions-internal.ts +++ b/core/src/definitions-internal.ts @@ -172,6 +172,8 @@ export interface CapacitorCustomPlatformInstance { export interface WindowCapacitor { Capacitor?: CapacitorInstance; + CapacitorCookiesAndroidInterface?: any; + CapacitorCookiesDescriptor?: PropertyDescriptor; CapacitorWebFetch?: any; CapacitorWebXMLHttpRequest?: any; /** diff --git a/core/src/index.ts b/core/src/index.ts index 2449f6ea2..e874a7294 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -20,10 +20,13 @@ export { Capacitor, registerPlugin } from './global'; export { WebPlugin, WebPluginConfig, ListenerCallback } from './web-plugin'; // Core Plugins APIs -export { CapacitorHttp, WebView } from './core-plugins'; +export { CapacitorCookies, CapacitorHttp, WebView } from './core-plugins'; // Core Plugin definitions export type { + ClearCookieOptions, + DeleteCookieOptions, + SetCookieOptions, HttpHeaders, HttpOptions, HttpParams, diff --git a/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj b/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj index 8fb7f7ec1..1cc058cfa 100644 --- a/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj +++ b/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj @@ -82,6 +82,8 @@ 62FABD1A25AE5C01007B3814 /* Array+Capacitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FABD1925AE5C01007B3814 /* Array+Capacitor.swift */; }; 62FABD2325AE60BA007B3814 /* BridgedTypesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 62FABD2225AE60BA007B3814 /* BridgedTypesTests.m */; }; 62FABD2B25AE6182007B3814 /* BridgedTypesHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FABD2A25AE6182007B3814 /* BridgedTypesHelper.swift */; }; + A38C3D7728484E76004B3680 /* CapacitorCookies.swift in Sources */ = {isa = PBXBuildFile; fileRef = A38C3D7628484E76004B3680 /* CapacitorCookies.swift */; }; + A38C3D7B2848BE6F004B3680 /* CapacitorCookieManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A38C3D7A2848BE6F004B3680 /* CapacitorCookieManager.swift */; }; A71289E627F380A500DADDF3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71289E527F380A500DADDF3 /* Router.swift */; }; A71289EB27F380FD00DADDF3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71289EA27F380FD00DADDF3 /* RouterTests.swift */; }; /* End PBXBuildFile section */ @@ -213,6 +215,8 @@ 62FABD1925AE5C01007B3814 /* Array+Capacitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Capacitor.swift"; sourceTree = ""; }; 62FABD2225AE60BA007B3814 /* BridgedTypesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BridgedTypesTests.m; sourceTree = ""; }; 62FABD2A25AE6182007B3814 /* BridgedTypesHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgedTypesHelper.swift; sourceTree = ""; }; + A38C3D7628484E76004B3680 /* CapacitorCookies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapacitorCookies.swift; sourceTree = ""; }; + A38C3D7A2848BE6F004B3680 /* CapacitorCookieManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapacitorCookieManager.swift; sourceTree = ""; }; A71289E527F380A500DADDF3 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; A71289EA27F380FD00DADDF3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -362,6 +366,8 @@ 62959AEF2524DA7700A3D7F1 /* Console.swift */, 62959AFD2524DA7700A3D7F1 /* DefaultPlugins.m */, 62959AF32524DA7700A3D7F1 /* WebView.swift */, + A38C3D7628484E76004B3680 /* CapacitorCookies.swift */, + A38C3D7A2848BE6F004B3680 /* CapacitorCookieManager.swift */, ); path = Plugins; sourceTree = ""; @@ -583,6 +589,7 @@ buildActionMask = 2147483647; files = ( 55F6736A26C371E6001E7AB9 /* CAPWebView.swift in Sources */, + A38C3D7728484E76004B3680 /* CapacitorCookies.swift in Sources */, A71289E627F380A500DADDF3 /* Router.swift in Sources */, 62959B362524DA7800A3D7F1 /* CAPBridgeViewController.swift in Sources */, 621ECCB72542045900D3D615 /* CAPBridgedJSTypes.m in Sources */, @@ -595,6 +602,7 @@ 621ECCDA254205C400D3D615 /* CapacitorBridge.swift in Sources */, 62959B382524DA7800A3D7F1 /* CAPPluginCall.m in Sources */, 623D690A254C6FDF002D01D1 /* CAPInstanceDescriptor.m in Sources */, + A38C3D7B2848BE6F004B3680 /* CapacitorCookieManager.swift in Sources */, 62959B1B2524DA7800A3D7F1 /* CAPFile.swift in Sources */, 62959B462524DA7800A3D7F1 /* CAPBridge.swift in Sources */, 62959B1D2524DA7800A3D7F1 /* UIColor.swift in Sources */, diff --git a/ios/Capacitor/Capacitor/Plugins/CapacitorCookieManager.swift b/ios/Capacitor/Capacitor/Plugins/CapacitorCookieManager.swift new file mode 100644 index 000000000..0534bb47c --- /dev/null +++ b/ios/Capacitor/Capacitor/Plugins/CapacitorCookieManager.swift @@ -0,0 +1,66 @@ +import Foundation + +public class CapacitorCookieManager { + var config: InstanceConfiguration? + + init(_ capConfig: InstanceConfiguration?) { + self.config = capConfig + } + + public func getServerUrl() -> URL? { + return self.config?.serverURL + } + + public func getServerUrl(_ call: CAPPluginCall) -> URL? { + guard let urlString = call.getString("url") else { + return getServerUrl() + } + + guard let url = URL(string: urlString) else { + return getServerUrl() + } + + return url + } + + public func encode(_ value: String) -> String { + return value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)! + } + + public func decode(_ value: String) -> String { + return value.removingPercentEncoding! + } + + public func setCookie(_ url: URL, _ key: String, _ value: String) { + let jar = HTTPCookieStorage.shared + let field = ["Set-Cookie": "\(key)=\(value)"] + let cookies = HTTPCookie.cookies(withResponseHeaderFields: field, for: url) + jar.setCookies(cookies, for: url, mainDocumentURL: url) + } + + public func getCookies() -> String { + let jar = HTTPCookieStorage.shared + guard let url = self.getServerUrl() else { return "" } + guard let cookies = jar.cookies(for: url) else { return "" } + return cookies.map({"\($0.name):\($0.value)"}).joined(separator: ";") + } + + public func deleteCookie(_ url: URL, _ key: String) { + let jar = HTTPCookieStorage.shared + let cookie = jar.cookies(for: url)?.first(where: { (cookie) -> Bool in + return cookie.name == key + }) + + if cookie != nil { jar.deleteCookie(cookie!) } + } + + public func clearCookies(_ url: URL) { + let jar = HTTPCookieStorage.shared + jar.cookies(for: url)?.forEach({ (cookie) in jar.deleteCookie(cookie) }) + } + + public func clearAllCookies() { + let jar = HTTPCookieStorage.shared + jar.cookies?.forEach({ (cookie) in jar.deleteCookie(cookie) }) + } +} diff --git a/ios/Capacitor/Capacitor/Plugins/CapacitorCookies.swift b/ios/Capacitor/Capacitor/Plugins/CapacitorCookies.swift new file mode 100644 index 000000000..b53f28fe9 --- /dev/null +++ b/ios/Capacitor/Capacitor/Plugins/CapacitorCookies.swift @@ -0,0 +1,52 @@ +import Foundation + +@objc(CAPCookiesPlugin) +public class CAPCookiesPlugin: CAPPlugin { + var cookieManager: CapacitorCookieManager? + + @objc override public func load() { + cookieManager = CapacitorCookieManager(bridge?.config) + } + + @objc func setCookie(_ call: CAPPluginCall) { + guard let key = call.getString("key") else { return call.reject("Must provide key") } + guard let value = call.getString("value") else { return call.reject("Must provide value") } + + let url = cookieManager!.getServerUrl(call) + if url != nil { + cookieManager!.setCookie(url!, key, cookieManager!.encode(value)) + call.resolve() + } + } + + @objc func deleteCookie(_ call: CAPPluginCall) { + guard let key = call.getString("key") else { return call.reject("Must provide key") } + let url = cookieManager!.getServerUrl(call) + if url != nil { + let jar = HTTPCookieStorage.shared + + let cookie = jar.cookies(for: url!)?.first(where: { (cookie) -> Bool in + return cookie.name == key + }) + + if cookie != nil { + jar.deleteCookie(cookie!) + } + + call.resolve() + } + } + + @objc func clearCookies(_ call: CAPPluginCall) { + let url = cookieManager!.getServerUrl(call) + if url != nil { + cookieManager!.clearCookies(url!) + call.resolve() + } + } + + @objc func clearAllCookies(_ call: CAPPluginCall) { + cookieManager!.clearAllCookies() + call.resolve() + } +} diff --git a/ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m b/ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m index 6aa071f1d..d98716a8b 100644 --- a/ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m +++ b/ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m @@ -2,6 +2,13 @@ #import "CAPBridgedPlugin.h" +CAP_PLUGIN(CAPCookiesPlugin, "CapacitorCookies", + CAP_PLUGIN_METHOD(setCookie, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(deleteCookie, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(clearCookies, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(clearAllCookies, CAPPluginReturnPromise); +) + CAP_PLUGIN(CAPConsolePlugin, "Console", CAP_PLUGIN_METHOD(log, CAPPluginReturnNone); ) diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift index 4ec797bc6..3fd30020e 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -243,6 +243,24 @@ internal class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDel } public func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) { + + // Check if this is synchronous cookie call + do { + if let dataFromString = prompt.data(using: .utf8, allowLossyConversion: false) { + if let payload = try JSONSerialization.jsonObject(with: dataFromString, options: .fragmentsAllowed) as? [String: AnyObject] { + let type = payload["type"] as? String + + if type == "CapacitorCookies" { + completionHandler(CapacitorCookieManager(bridge!.config).getCookies()) + // Don't present prompt + return + } + } + } + } catch { + // Continue with regular prompt + } + guard let viewController = bridge?.viewController else { return } diff --git a/ios/Capacitor/Capacitor/assets/native-bridge.js b/ios/Capacitor/Capacitor/assets/native-bridge.js index a4bd18ea3..8cfa2efa7 100644 --- a/ios/Capacitor/Capacitor/assets/native-bridge.js +++ b/ios/Capacitor/Capacitor/assets/native-bridge.js @@ -241,10 +241,48 @@ const nativeBridge = (function (exports) { } return String(msg); }; + /** + * Safely web decode a string value (inspired by js-cookie) + * @param str The string value to decode + */ + const decode = (str) => str.replace(/(%[\dA-F]{2})+/gi, decodeURIComponent); const platform = getPlatformId(win); - // TODO: Check cap config for opt-out - // patch fetch / XHR on Android/iOS if (platform == 'android' || platform == 'ios') { + // patch document.cookie on Android/iOS + win.CapacitorCookiesDescriptor = + Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') || + Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie'); + Object.defineProperty(document, 'cookie', { + get: function () { + if (platform === 'ios') { + // Use prompt to synchronously get cookies. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + const payload = { + type: 'CapacitorCookies', + }; + const res = prompt(JSON.stringify(payload)); + return res; + } + else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') { + return win.CapacitorCookiesAndroidInterface.getCookies(); + } + }, + set: function (val) { + const cookiePairs = val.split(';'); + for (const cookiePair of cookiePairs) { + const cookieKey = cookiePair.split('=')[0]; + const cookieValue = cookiePair.split('=')[1]; + if (null == cookieValue) { + continue; + } + cap.toNative('CapacitorCookies', 'setCookie', { + key: cookieKey, + value: decode(cookieValue), + }); + } + }, + }); + // patch fetch / XHR on Android/iOS // store original fetch & XHR functions win.CapacitorWebFetch = window.fetch; win.CapacitorWebXMLHttpRequest = { diff --git a/ios/LICENSE b/ios/LICENSE index 941061474..c3e903bdd 100644 --- a/ios/LICENSE +++ b/ios/LICENSE @@ -1,23 +1,21 @@ -Copyright 2017-present Ionic -https://ionic.io - MIT License -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Copyright (c) 2017-present Drifty Co. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 7e3589f9bb4673b595f2718e444e2bd07c7749e1 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Wed, 7 Sep 2022 08:36:14 -0700 Subject: [PATCH 04/15] chore: allow branch for ci dev release --- android/capacitor/src/main/assets/native-bridge.js | 3 +++ core/native-bridge.ts | 4 ++++ ios/Capacitor/Capacitor/Plugins/CapacitorCookieManager.swift | 2 +- ios/Capacitor/Capacitor/assets/native-bridge.js | 3 +++ package.json | 2 +- 5 files changed, 12 insertions(+), 2 deletions(-) diff --git a/android/capacitor/src/main/assets/native-bridge.js b/android/capacitor/src/main/assets/native-bridge.js index 8cfa2efa7..67848de96 100644 --- a/android/capacitor/src/main/assets/native-bridge.js +++ b/android/capacitor/src/main/assets/native-bridge.js @@ -293,6 +293,9 @@ const nativeBridge = (function (exports) { }; // fetch patch window.fetch = async (resource, options) => { + if (resource.toString().startsWith('data:')) { + return win.CapacitorWebFetch(resource, options); + } try { // intercept request & pass to the bridge const nativeResponse = await cap.nativePromise('CapacitorHttp', 'request', { diff --git a/core/native-bridge.ts b/core/native-bridge.ts index 6df0e07d4..64610c82e 100644 --- a/core/native-bridge.ts +++ b/core/native-bridge.ts @@ -353,6 +353,10 @@ const initBridge = (w: any): void => { resource: RequestInfo | URL, options?: RequestInit, ) => { + if (resource.toString().startsWith('data:')) { + return win.CapacitorWebFetch(resource, options); + } + try { // intercept request & pass to the bridge const nativeResponse: HttpResponse = await cap.nativePromise( diff --git a/ios/Capacitor/Capacitor/Plugins/CapacitorCookieManager.swift b/ios/Capacitor/Capacitor/Plugins/CapacitorCookieManager.swift index 0534bb47c..9249ebd67 100644 --- a/ios/Capacitor/Capacitor/Plugins/CapacitorCookieManager.swift +++ b/ios/Capacitor/Capacitor/Plugins/CapacitorCookieManager.swift @@ -42,7 +42,7 @@ public class CapacitorCookieManager { let jar = HTTPCookieStorage.shared guard let url = self.getServerUrl() else { return "" } guard let cookies = jar.cookies(for: url) else { return "" } - return cookies.map({"\($0.name):\($0.value)"}).joined(separator: ";") + return cookies.map({"\($0.name)=\($0.value)"}).joined(separator: ";") } public func deleteCookie(_ url: URL, _ key: String) { diff --git a/ios/Capacitor/Capacitor/assets/native-bridge.js b/ios/Capacitor/Capacitor/assets/native-bridge.js index 8cfa2efa7..67848de96 100644 --- a/ios/Capacitor/Capacitor/assets/native-bridge.js +++ b/ios/Capacitor/Capacitor/assets/native-bridge.js @@ -293,6 +293,9 @@ const nativeBridge = (function (exports) { }; // fetch patch window.fetch = async (resource, options) => { + if (resource.toString().startsWith('data:')) { + return win.CapacitorWebFetch(resource, options); + } try { // intercept request & pass to the bridge const nativeResponse = await cap.nativePromise('CapacitorHttp', 'request', { diff --git a/package.json b/package.json index f01f5a687..3591664e3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "ci:publish:beta": "lerna publish prerelease --conventional-commits --conventional-prerelease --preid beta --dist-tag next --force-publish --no-verify-access --yes", "ci:publish:rc": "lerna publish prerelease --conventional-commits --conventional-prerelease --preid rc --dist-tag next --force-publish --no-verify-access --yes", "ci:publish:latest": "lerna publish --conventional-commits --dist-tag latest --force-publish --no-verify-access --yes", - "ci:publish:dev": "lerna publish prerelease --conventional-commits --conventional-prerelease --preid dev-$(git rev-parse --short HEAD) --dist-tag dev --force-publish --no-verify-access --no-changelog --no-git-tag-version --no-push --yes", + "ci:publish:dev": "lerna publish prerelease --conventional-commits --conventional-prerelease --preid dev-$(git rev-parse --short HEAD) --dist-tag dev --force-publish --no-verify-access --no-changelog --no-git-tag-version --no-push --yes --allow-branch feat/cookies-http-merged", "build:nativebridge": "lerna run build:nativebridge", "sync-peer-dependencies": "node scripts/sync-peer-dependencies.mjs", "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint", From c03a33d3a479db6e3897d70245d9803998acb6d6 Mon Sep 17 00:00:00 2001 From: IT-MikeS <20338451+IT-MikeS@users.noreply.github.com> Date: Wed, 7 Sep 2022 11:52:44 -0400 Subject: [PATCH 05/15] chore: allow dev release --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3591664e3..99e7f576f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "ci:publish:beta": "lerna publish prerelease --conventional-commits --conventional-prerelease --preid beta --dist-tag next --force-publish --no-verify-access --yes", "ci:publish:rc": "lerna publish prerelease --conventional-commits --conventional-prerelease --preid rc --dist-tag next --force-publish --no-verify-access --yes", "ci:publish:latest": "lerna publish --conventional-commits --dist-tag latest --force-publish --no-verify-access --yes", - "ci:publish:dev": "lerna publish prerelease --conventional-commits --conventional-prerelease --preid dev-$(git rev-parse --short HEAD) --dist-tag dev --force-publish --no-verify-access --no-changelog --no-git-tag-version --no-push --yes --allow-branch feat/cookies-http-merged", + "ci:publish:dev": "lerna publish prerelease --conventional-commits --conventional-prerelease --preid dev-$(git rev-parse --short HEAD)-$(date +\"%Y%m%dT%H%M\") --dist-tag dev --force-publish --no-verify-access --no-changelog --no-git-tag-version --no-push --yes --allow-branch feat/cookies-http-merged", "build:nativebridge": "lerna run build:nativebridge", "sync-peer-dependencies": "node scripts/sync-peer-dependencies.mjs", "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint", From 933059243aaa5c8df3786a5574e60ec804de818e Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Wed, 7 Sep 2022 14:58:42 -0700 Subject: [PATCH 06/15] feat: add support for disabling http via config --- .../src/main/assets/native-bridge.js | 301 ++++++++-------- .../getcapacitor/plugin/CapacitorHttp.java | 6 + core/native-bridge.ts | 330 ++++++++++-------- core/src/core-plugins.ts | 15 + core/src/definitions-internal.ts | 1 + .../Capacitor/WebViewDelegationHandler.swift | 7 +- .../Capacitor/assets/native-bridge.js | 301 ++++++++-------- 7 files changed, 527 insertions(+), 434 deletions(-) diff --git a/android/capacitor/src/main/assets/native-bridge.js b/android/capacitor/src/main/assets/native-bridge.js index 67848de96..d1cabe44a 100644 --- a/android/capacitor/src/main/assets/native-bridge.js +++ b/android/capacitor/src/main/assets/native-bridge.js @@ -291,158 +291,179 @@ const nativeBridge = (function (exports) { send: window.XMLHttpRequest.prototype.send, setRequestHeader: window.XMLHttpRequest.prototype.setRequestHeader, }; - // fetch patch - window.fetch = async (resource, options) => { - if (resource.toString().startsWith('data:')) { - return win.CapacitorWebFetch(resource, options); + let doPatchHttp = true; + // check if capacitor http is disabled before patching + if (platform === 'ios') { + // Use prompt to synchronously get capacitor http config. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + const payload = { + type: 'CapacitorHttp', + }; + const isDisabled = prompt(JSON.stringify(payload)); + if (isDisabled === 'true') { + doPatchHttp = false; } - try { - // intercept request & pass to the bridge - const nativeResponse = await cap.nativePromise('CapacitorHttp', 'request', { - url: resource, - method: (options === null || options === void 0 ? void 0 : options.method) ? options.method : undefined, - data: (options === null || options === void 0 ? void 0 : options.body) ? options.body : undefined, - headers: (options === null || options === void 0 ? void 0 : options.headers) ? options.headers : undefined, + } + else if (typeof win.CapacitorHttpAndroidInterface !== 'undefined') { + const isDisabled = win.CapacitorHttpAndroidInterface.isDisabled(); + if (isDisabled === true) { + doPatchHttp = false; + } + } + if (doPatchHttp) { + // fetch patch + window.fetch = async (resource, options) => { + if (resource.toString().startsWith('data:')) { + return win.CapacitorWebFetch(resource, options); + } + try { + // intercept request & pass to the bridge + const nativeResponse = await cap.nativePromise('CapacitorHttp', 'request', { + url: resource, + method: (options === null || options === void 0 ? void 0 : options.method) ? options.method : undefined, + data: (options === null || options === void 0 ? void 0 : options.body) ? options.body : undefined, + headers: (options === null || options === void 0 ? void 0 : options.headers) ? options.headers : undefined, + }); + // intercept & parse response before returning + const response = new Response(JSON.stringify(nativeResponse.data), { + headers: nativeResponse.headers, + status: nativeResponse.status, + }); + return response; + } + catch (error) { + return Promise.reject(error); + } + }; + // XHR event listeners + const addEventListeners = function () { + this.addEventListener('abort', function () { + if (typeof this.onabort === 'function') + this.onabort(); }); - // intercept & parse response before returning - const response = new Response(JSON.stringify(nativeResponse.data), { - headers: nativeResponse.headers, - status: nativeResponse.status, + this.addEventListener('error', function () { + if (typeof this.onerror === 'function') + this.onerror(); }); - return response; - } - catch (error) { - return Promise.reject(error); - } - }; - // XHR event listeners - const addEventListeners = function () { - this.addEventListener('abort', function () { - if (typeof this.onabort === 'function') - this.onabort(); - }); - this.addEventListener('error', function () { - if (typeof this.onerror === 'function') - this.onerror(); - }); - this.addEventListener('load', function () { - if (typeof this.onload === 'function') - this.onload(); - }); - this.addEventListener('loadend', function () { - if (typeof this.onloadend === 'function') - this.onloadend(); - }); - this.addEventListener('loadstart', function () { - if (typeof this.onloadstart === 'function') - this.onloadstart(); - }); - this.addEventListener('readystatechange', function () { - if (typeof this.onreadystatechange === 'function') - this.onreadystatechange(); - }); - this.addEventListener('timeout', function () { - if (typeof this.ontimeout === 'function') - this.ontimeout(); - }); - }; - // XHR patch abort - window.XMLHttpRequest.prototype.abort = function () { - Object.defineProperties(this, { - _headers: { - value: {}, - writable: true, - }, - readyState: { - get: function () { - var _a; - return (_a = this._readyState) !== null && _a !== void 0 ? _a : 0; + this.addEventListener('load', function () { + if (typeof this.onload === 'function') + this.onload(); + }); + this.addEventListener('loadend', function () { + if (typeof this.onloadend === 'function') + this.onloadend(); + }); + this.addEventListener('loadstart', function () { + if (typeof this.onloadstart === 'function') + this.onloadstart(); + }); + this.addEventListener('readystatechange', function () { + if (typeof this.onreadystatechange === 'function') + this.onreadystatechange(); + }); + this.addEventListener('timeout', function () { + if (typeof this.ontimeout === 'function') + this.ontimeout(); + }); + }; + // XHR patch abort + window.XMLHttpRequest.prototype.abort = function () { + Object.defineProperties(this, { + _headers: { + value: {}, + writable: true, }, - set: function (val) { - this._readyState = val; - this.dispatchEvent(new Event('readystatechange')); + readyState: { + get: function () { + var _a; + return (_a = this._readyState) !== null && _a !== void 0 ? _a : 0; + }, + set: function (val) { + this._readyState = val; + this.dispatchEvent(new Event('readystatechange')); + }, }, - }, - response: { - value: '', - writable: true, - }, - responseText: { - value: '', - writable: true, - }, - responseURL: { - value: '', - writable: true, - }, - status: { - value: 0, - writable: true, - }, - }); - this.readyState = 0; - this.dispatchEvent(new Event('abort')); - this.dispatchEvent(new Event('loadend')); - }; - // XHR patch open - window.XMLHttpRequest.prototype.open = function (method, url) { - this.abort(); - addEventListeners.call(this); - this._method = method; - this._url = url; - this.readyState = 1; - }; - // XHR patch set request header - window.XMLHttpRequest.prototype.setRequestHeader = function (header, value) { - this._headers[header] = value; - }; - // XHR patch send - window.XMLHttpRequest.prototype.send = function (body) { - try { - this.readyState = 2; - // intercept request & pass to the bridge - cap - .nativePromise('CapacitorHttp', 'request', { - url: this._url, - method: this._method, - data: body !== null ? body : undefined, - headers: this._headers, - }) - .then((nativeResponse) => { - // intercept & parse response before returning - if (this.readyState == 2) { + response: { + value: '', + writable: true, + }, + responseText: { + value: '', + writable: true, + }, + responseURL: { + value: '', + writable: true, + }, + status: { + value: 0, + writable: true, + }, + }); + this.readyState = 0; + this.dispatchEvent(new Event('abort')); + this.dispatchEvent(new Event('loadend')); + }; + // XHR patch open + window.XMLHttpRequest.prototype.open = function (method, url) { + this.abort(); + addEventListeners.call(this); + this._method = method; + this._url = url; + this.readyState = 1; + }; + // XHR patch set request header + window.XMLHttpRequest.prototype.setRequestHeader = function (header, value) { + this._headers[header] = value; + }; + // XHR patch send + window.XMLHttpRequest.prototype.send = function (body) { + try { + this.readyState = 2; + // intercept request & pass to the bridge + cap + .nativePromise('CapacitorHttp', 'request', { + url: this._url, + method: this._method, + data: body !== null ? body : undefined, + headers: this._headers, + }) + .then((nativeResponse) => { + // intercept & parse response before returning + if (this.readyState == 2) { + this.dispatchEvent(new Event('loadstart')); + this.status = nativeResponse.status; + this.response = nativeResponse.data; + this.responseText = JSON.stringify(nativeResponse.data); + this.responseURL = nativeResponse.url; + this.readyState = 4; + this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('loadend')); + } + }) + .catch((error) => { this.dispatchEvent(new Event('loadstart')); - this.status = nativeResponse.status; - this.response = nativeResponse.data; - this.responseText = JSON.stringify(nativeResponse.data); - this.responseURL = nativeResponse.url; + this.status = error.status; + this.response = error.data; + this.responseText = JSON.stringify(error.data); + this.responseURL = error.url; this.readyState = 4; - this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('error')); this.dispatchEvent(new Event('loadend')); - } - }) - .catch((error) => { + }); + } + catch (error) { this.dispatchEvent(new Event('loadstart')); - this.status = error.status; - this.response = error.data; - this.responseText = JSON.stringify(error.data); - this.responseURL = error.url; + this.status = 500; + this.response = error; + this.responseText = error.toString(); + this.responseURL = this._url; this.readyState = 4; this.dispatchEvent(new Event('error')); this.dispatchEvent(new Event('loadend')); - }); - } - catch (error) { - this.dispatchEvent(new Event('loadstart')); - this.status = 500; - this.response = error; - this.responseText = error.toString(); - this.responseURL = this._url; - this.readyState = 4; - this.dispatchEvent(new Event('error')); - this.dispatchEvent(new Event('loadend')); - } - }; + } + }; + } } // patch window.console on iOS and store original console fns const isIos = getPlatformId(win) === 'ios'; diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java index ff2925695..02c14d387 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java @@ -20,6 +20,12 @@ ) public class CapacitorHttp extends Plugin { + @Override + public void load() { + this.bridge.getWebView().addJavascriptInterface(this, "CapacitorHttpAndroidInterface"); + super.load(); + } + private void http(final PluginCall call, final String httpMethod) { Runnable asyncHttpCall = new Runnable() { @Override diff --git a/core/native-bridge.ts b/core/native-bridge.ts index 64610c82e..bf632ddd7 100644 --- a/core/native-bridge.ts +++ b/core/native-bridge.ts @@ -348,179 +348,203 @@ const initBridge = (w: any): void => { setRequestHeader: window.XMLHttpRequest.prototype.setRequestHeader, }; - // fetch patch - window.fetch = async ( - resource: RequestInfo | URL, - options?: RequestInit, - ) => { - if (resource.toString().startsWith('data:')) { - return win.CapacitorWebFetch(resource, options); - } + let doPatchHttp = true; - try { - // intercept request & pass to the bridge - const nativeResponse: HttpResponse = await cap.nativePromise( - 'CapacitorHttp', - 'request', - { - url: resource, - method: options?.method ? options.method : undefined, - data: options?.body ? options.body : undefined, - headers: options?.headers ? options.headers : undefined, - }, - ); + // check if capacitor http is disabled before patching + if (platform === 'ios') { + // Use prompt to synchronously get capacitor http config. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 - // intercept & parse response before returning - const response = new Response(JSON.stringify(nativeResponse.data), { - headers: nativeResponse.headers, - status: nativeResponse.status, - }); + const payload = { + type: 'CapacitorHttp', + }; - return response; - } catch (error) { - return Promise.reject(error); + const isDisabled = prompt(JSON.stringify(payload)); + if (isDisabled === 'true') { + doPatchHttp = false; } - }; + } else if (typeof win.CapacitorHttpAndroidInterface !== 'undefined') { + const isDisabled = win.CapacitorHttpAndroidInterface.isDisabled(); + if (isDisabled === true) { + doPatchHttp = false; + } + } - // XHR event listeners - const addEventListeners = function () { - this.addEventListener('abort', function () { - if (typeof this.onabort === 'function') this.onabort(); - }); + if (doPatchHttp) { + // fetch patch + window.fetch = async ( + resource: RequestInfo | URL, + options?: RequestInit, + ) => { + if (resource.toString().startsWith('data:')) { + return win.CapacitorWebFetch(resource, options); + } - this.addEventListener('error', function () { - if (typeof this.onerror === 'function') this.onerror(); - }); + try { + // intercept request & pass to the bridge + const nativeResponse: HttpResponse = await cap.nativePromise( + 'CapacitorHttp', + 'request', + { + url: resource, + method: options?.method ? options.method : undefined, + data: options?.body ? options.body : undefined, + headers: options?.headers ? options.headers : undefined, + }, + ); + + // intercept & parse response before returning + const response = new Response(JSON.stringify(nativeResponse.data), { + headers: nativeResponse.headers, + status: nativeResponse.status, + }); - this.addEventListener('load', function () { - if (typeof this.onload === 'function') this.onload(); - }); + return response; + } catch (error) { + return Promise.reject(error); + } + }; - this.addEventListener('loadend', function () { - if (typeof this.onloadend === 'function') this.onloadend(); - }); + // XHR event listeners + const addEventListeners = function () { + this.addEventListener('abort', function () { + if (typeof this.onabort === 'function') this.onabort(); + }); - this.addEventListener('loadstart', function () { - if (typeof this.onloadstart === 'function') this.onloadstart(); - }); + this.addEventListener('error', function () { + if (typeof this.onerror === 'function') this.onerror(); + }); - this.addEventListener('readystatechange', function () { - if (typeof this.onreadystatechange === 'function') - this.onreadystatechange(); - }); + this.addEventListener('load', function () { + if (typeof this.onload === 'function') this.onload(); + }); - this.addEventListener('timeout', function () { - if (typeof this.ontimeout === 'function') this.ontimeout(); - }); - }; + this.addEventListener('loadend', function () { + if (typeof this.onloadend === 'function') this.onloadend(); + }); - // XHR patch abort - window.XMLHttpRequest.prototype.abort = function () { - Object.defineProperties(this, { - _headers: { - value: {}, - writable: true, - }, - readyState: { - get: function () { - return this._readyState ?? 0; + this.addEventListener('loadstart', function () { + if (typeof this.onloadstart === 'function') this.onloadstart(); + }); + + this.addEventListener('readystatechange', function () { + if (typeof this.onreadystatechange === 'function') + this.onreadystatechange(); + }); + + this.addEventListener('timeout', function () { + if (typeof this.ontimeout === 'function') this.ontimeout(); + }); + }; + + // XHR patch abort + window.XMLHttpRequest.prototype.abort = function () { + Object.defineProperties(this, { + _headers: { + value: {}, + writable: true, }, - set: function (val: number) { - this._readyState = val; - this.dispatchEvent(new Event('readystatechange')); + readyState: { + get: function () { + return this._readyState ?? 0; + }, + set: function (val: number) { + this._readyState = val; + this.dispatchEvent(new Event('readystatechange')); + }, }, - }, - response: { - value: '', - writable: true, - }, - responseText: { - value: '', - writable: true, - }, - responseURL: { - value: '', - writable: true, - }, - status: { - value: 0, - writable: true, - }, - }); - this.readyState = 0; - this.dispatchEvent(new Event('abort')); - this.dispatchEvent(new Event('loadend')); - }; + response: { + value: '', + writable: true, + }, + responseText: { + value: '', + writable: true, + }, + responseURL: { + value: '', + writable: true, + }, + status: { + value: 0, + writable: true, + }, + }); + this.readyState = 0; + this.dispatchEvent(new Event('abort')); + this.dispatchEvent(new Event('loadend')); + }; - // XHR patch open - window.XMLHttpRequest.prototype.open = function ( - method: string, - url: string, - ) { - this.abort(); - addEventListeners.call(this); - this._method = method; - this._url = url; - this.readyState = 1; - }; + // XHR patch open + window.XMLHttpRequest.prototype.open = function ( + method: string, + url: string, + ) { + this.abort(); + addEventListeners.call(this); + this._method = method; + this._url = url; + this.readyState = 1; + }; - // XHR patch set request header - window.XMLHttpRequest.prototype.setRequestHeader = function ( - header: string, - value: string, - ) { - this._headers[header] = value; - }; + // XHR patch set request header + window.XMLHttpRequest.prototype.setRequestHeader = function ( + header: string, + value: string, + ) { + this._headers[header] = value; + }; - // XHR patch send - window.XMLHttpRequest.prototype.send = function ( - body?: Document | XMLHttpRequestBodyInit, - ) { - try { - this.readyState = 2; - - // intercept request & pass to the bridge - cap - .nativePromise('CapacitorHttp', 'request', { - url: this._url, - method: this._method, - data: body !== null ? body : undefined, - headers: this._headers, - }) - .then((nativeResponse: any) => { - // intercept & parse response before returning - if (this.readyState == 2) { + // XHR patch send + window.XMLHttpRequest.prototype.send = function ( + body?: Document | XMLHttpRequestBodyInit, + ) { + try { + this.readyState = 2; + + // intercept request & pass to the bridge + cap + .nativePromise('CapacitorHttp', 'request', { + url: this._url, + method: this._method, + data: body !== null ? body : undefined, + headers: this._headers, + }) + .then((nativeResponse: any) => { + // intercept & parse response before returning + if (this.readyState == 2) { + this.dispatchEvent(new Event('loadstart')); + this.status = nativeResponse.status; + this.response = nativeResponse.data; + this.responseText = JSON.stringify(nativeResponse.data); + this.responseURL = nativeResponse.url; + this.readyState = 4; + this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('loadend')); + } + }) + .catch((error: any) => { this.dispatchEvent(new Event('loadstart')); - this.status = nativeResponse.status; - this.response = nativeResponse.data; - this.responseText = JSON.stringify(nativeResponse.data); - this.responseURL = nativeResponse.url; + this.status = error.status; + this.response = error.data; + this.responseText = JSON.stringify(error.data); + this.responseURL = error.url; this.readyState = 4; - this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('error')); this.dispatchEvent(new Event('loadend')); - } - }) - .catch((error: any) => { - this.dispatchEvent(new Event('loadstart')); - this.status = error.status; - this.response = error.data; - this.responseText = JSON.stringify(error.data); - this.responseURL = error.url; - this.readyState = 4; - this.dispatchEvent(new Event('error')); - this.dispatchEvent(new Event('loadend')); - }); - } catch (error) { - this.dispatchEvent(new Event('loadstart')); - this.status = 500; - this.response = error; - this.responseText = error.toString(); - this.responseURL = this._url; - this.readyState = 4; - this.dispatchEvent(new Event('error')); - this.dispatchEvent(new Event('loadend')); - } - }; + }); + } catch (error) { + this.dispatchEvent(new Event('loadstart')); + this.status = 500; + this.response = error; + this.responseText = error.toString(); + this.responseURL = this._url; + this.readyState = 4; + this.dispatchEvent(new Event('error')); + this.dispatchEvent(new Event('loadend')); + } + }; + } } // patch window.console on iOS and store original console fns diff --git a/core/src/core-plugins.ts b/core/src/core-plugins.ts index 371994a3e..0f25fbe29 100644 --- a/core/src/core-plugins.ts +++ b/core/src/core-plugins.ts @@ -1,3 +1,5 @@ +/// + import type { Plugin } from './definitions'; import { registerPlugin } from './global'; import { WebPlugin } from './web-plugin'; @@ -114,6 +116,19 @@ export const CapacitorCookies = registerPlugin( /******** END COOKIES PLUGIN ********/ /******** HTTP PLUGIN ********/ +declare module '../../cli' { + export interface PluginsConfig { + CapacitorHttp?: { + /** + * Disable CapacitorHttp from monkey patching the global fetch and XMLHttpRequest + * + * @default false + */ + disabled?: boolean; + }; + } +} + export interface CapacitorHttpPlugin { request(options: HttpOptions): Promise; get(options: HttpOptions): Promise; diff --git a/core/src/definitions-internal.ts b/core/src/definitions-internal.ts index 721a694e6..2f058dffa 100644 --- a/core/src/definitions-internal.ts +++ b/core/src/definitions-internal.ts @@ -174,6 +174,7 @@ export interface WindowCapacitor { Capacitor?: CapacitorInstance; CapacitorCookiesAndroidInterface?: any; CapacitorCookiesDescriptor?: PropertyDescriptor; + CapacitorHttpAndroidInterface?: any; CapacitorWebFetch?: any; CapacitorWebXMLHttpRequest?: any; /** diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift index 3fd30020e..ccbdf9fda 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -244,7 +244,7 @@ internal class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDel public func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) { - // Check if this is synchronous cookie call + // Check if this is synchronous cookie or http call do { if let dataFromString = prompt.data(using: .utf8, allowLossyConversion: false) { if let payload = try JSONSerialization.jsonObject(with: dataFromString, options: .fragmentsAllowed) as? [String: AnyObject] { @@ -254,6 +254,11 @@ internal class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDel completionHandler(CapacitorCookieManager(bridge!.config).getCookies()) // Don't present prompt return + } else if type == "CapacitorHttp" { + let pluginConfig = bridge!.config.getPluginConfig("CapacitorHttp") + completionHandler(String(pluginConfig.getBoolean("disabled", false))) + // Don't present prompt + return } } } diff --git a/ios/Capacitor/Capacitor/assets/native-bridge.js b/ios/Capacitor/Capacitor/assets/native-bridge.js index 67848de96..d1cabe44a 100644 --- a/ios/Capacitor/Capacitor/assets/native-bridge.js +++ b/ios/Capacitor/Capacitor/assets/native-bridge.js @@ -291,158 +291,179 @@ const nativeBridge = (function (exports) { send: window.XMLHttpRequest.prototype.send, setRequestHeader: window.XMLHttpRequest.prototype.setRequestHeader, }; - // fetch patch - window.fetch = async (resource, options) => { - if (resource.toString().startsWith('data:')) { - return win.CapacitorWebFetch(resource, options); + let doPatchHttp = true; + // check if capacitor http is disabled before patching + if (platform === 'ios') { + // Use prompt to synchronously get capacitor http config. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + const payload = { + type: 'CapacitorHttp', + }; + const isDisabled = prompt(JSON.stringify(payload)); + if (isDisabled === 'true') { + doPatchHttp = false; } - try { - // intercept request & pass to the bridge - const nativeResponse = await cap.nativePromise('CapacitorHttp', 'request', { - url: resource, - method: (options === null || options === void 0 ? void 0 : options.method) ? options.method : undefined, - data: (options === null || options === void 0 ? void 0 : options.body) ? options.body : undefined, - headers: (options === null || options === void 0 ? void 0 : options.headers) ? options.headers : undefined, + } + else if (typeof win.CapacitorHttpAndroidInterface !== 'undefined') { + const isDisabled = win.CapacitorHttpAndroidInterface.isDisabled(); + if (isDisabled === true) { + doPatchHttp = false; + } + } + if (doPatchHttp) { + // fetch patch + window.fetch = async (resource, options) => { + if (resource.toString().startsWith('data:')) { + return win.CapacitorWebFetch(resource, options); + } + try { + // intercept request & pass to the bridge + const nativeResponse = await cap.nativePromise('CapacitorHttp', 'request', { + url: resource, + method: (options === null || options === void 0 ? void 0 : options.method) ? options.method : undefined, + data: (options === null || options === void 0 ? void 0 : options.body) ? options.body : undefined, + headers: (options === null || options === void 0 ? void 0 : options.headers) ? options.headers : undefined, + }); + // intercept & parse response before returning + const response = new Response(JSON.stringify(nativeResponse.data), { + headers: nativeResponse.headers, + status: nativeResponse.status, + }); + return response; + } + catch (error) { + return Promise.reject(error); + } + }; + // XHR event listeners + const addEventListeners = function () { + this.addEventListener('abort', function () { + if (typeof this.onabort === 'function') + this.onabort(); }); - // intercept & parse response before returning - const response = new Response(JSON.stringify(nativeResponse.data), { - headers: nativeResponse.headers, - status: nativeResponse.status, + this.addEventListener('error', function () { + if (typeof this.onerror === 'function') + this.onerror(); }); - return response; - } - catch (error) { - return Promise.reject(error); - } - }; - // XHR event listeners - const addEventListeners = function () { - this.addEventListener('abort', function () { - if (typeof this.onabort === 'function') - this.onabort(); - }); - this.addEventListener('error', function () { - if (typeof this.onerror === 'function') - this.onerror(); - }); - this.addEventListener('load', function () { - if (typeof this.onload === 'function') - this.onload(); - }); - this.addEventListener('loadend', function () { - if (typeof this.onloadend === 'function') - this.onloadend(); - }); - this.addEventListener('loadstart', function () { - if (typeof this.onloadstart === 'function') - this.onloadstart(); - }); - this.addEventListener('readystatechange', function () { - if (typeof this.onreadystatechange === 'function') - this.onreadystatechange(); - }); - this.addEventListener('timeout', function () { - if (typeof this.ontimeout === 'function') - this.ontimeout(); - }); - }; - // XHR patch abort - window.XMLHttpRequest.prototype.abort = function () { - Object.defineProperties(this, { - _headers: { - value: {}, - writable: true, - }, - readyState: { - get: function () { - var _a; - return (_a = this._readyState) !== null && _a !== void 0 ? _a : 0; + this.addEventListener('load', function () { + if (typeof this.onload === 'function') + this.onload(); + }); + this.addEventListener('loadend', function () { + if (typeof this.onloadend === 'function') + this.onloadend(); + }); + this.addEventListener('loadstart', function () { + if (typeof this.onloadstart === 'function') + this.onloadstart(); + }); + this.addEventListener('readystatechange', function () { + if (typeof this.onreadystatechange === 'function') + this.onreadystatechange(); + }); + this.addEventListener('timeout', function () { + if (typeof this.ontimeout === 'function') + this.ontimeout(); + }); + }; + // XHR patch abort + window.XMLHttpRequest.prototype.abort = function () { + Object.defineProperties(this, { + _headers: { + value: {}, + writable: true, }, - set: function (val) { - this._readyState = val; - this.dispatchEvent(new Event('readystatechange')); + readyState: { + get: function () { + var _a; + return (_a = this._readyState) !== null && _a !== void 0 ? _a : 0; + }, + set: function (val) { + this._readyState = val; + this.dispatchEvent(new Event('readystatechange')); + }, }, - }, - response: { - value: '', - writable: true, - }, - responseText: { - value: '', - writable: true, - }, - responseURL: { - value: '', - writable: true, - }, - status: { - value: 0, - writable: true, - }, - }); - this.readyState = 0; - this.dispatchEvent(new Event('abort')); - this.dispatchEvent(new Event('loadend')); - }; - // XHR patch open - window.XMLHttpRequest.prototype.open = function (method, url) { - this.abort(); - addEventListeners.call(this); - this._method = method; - this._url = url; - this.readyState = 1; - }; - // XHR patch set request header - window.XMLHttpRequest.prototype.setRequestHeader = function (header, value) { - this._headers[header] = value; - }; - // XHR patch send - window.XMLHttpRequest.prototype.send = function (body) { - try { - this.readyState = 2; - // intercept request & pass to the bridge - cap - .nativePromise('CapacitorHttp', 'request', { - url: this._url, - method: this._method, - data: body !== null ? body : undefined, - headers: this._headers, - }) - .then((nativeResponse) => { - // intercept & parse response before returning - if (this.readyState == 2) { + response: { + value: '', + writable: true, + }, + responseText: { + value: '', + writable: true, + }, + responseURL: { + value: '', + writable: true, + }, + status: { + value: 0, + writable: true, + }, + }); + this.readyState = 0; + this.dispatchEvent(new Event('abort')); + this.dispatchEvent(new Event('loadend')); + }; + // XHR patch open + window.XMLHttpRequest.prototype.open = function (method, url) { + this.abort(); + addEventListeners.call(this); + this._method = method; + this._url = url; + this.readyState = 1; + }; + // XHR patch set request header + window.XMLHttpRequest.prototype.setRequestHeader = function (header, value) { + this._headers[header] = value; + }; + // XHR patch send + window.XMLHttpRequest.prototype.send = function (body) { + try { + this.readyState = 2; + // intercept request & pass to the bridge + cap + .nativePromise('CapacitorHttp', 'request', { + url: this._url, + method: this._method, + data: body !== null ? body : undefined, + headers: this._headers, + }) + .then((nativeResponse) => { + // intercept & parse response before returning + if (this.readyState == 2) { + this.dispatchEvent(new Event('loadstart')); + this.status = nativeResponse.status; + this.response = nativeResponse.data; + this.responseText = JSON.stringify(nativeResponse.data); + this.responseURL = nativeResponse.url; + this.readyState = 4; + this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('loadend')); + } + }) + .catch((error) => { this.dispatchEvent(new Event('loadstart')); - this.status = nativeResponse.status; - this.response = nativeResponse.data; - this.responseText = JSON.stringify(nativeResponse.data); - this.responseURL = nativeResponse.url; + this.status = error.status; + this.response = error.data; + this.responseText = JSON.stringify(error.data); + this.responseURL = error.url; this.readyState = 4; - this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('error')); this.dispatchEvent(new Event('loadend')); - } - }) - .catch((error) => { + }); + } + catch (error) { this.dispatchEvent(new Event('loadstart')); - this.status = error.status; - this.response = error.data; - this.responseText = JSON.stringify(error.data); - this.responseURL = error.url; + this.status = 500; + this.response = error; + this.responseText = error.toString(); + this.responseURL = this._url; this.readyState = 4; this.dispatchEvent(new Event('error')); this.dispatchEvent(new Event('loadend')); - }); - } - catch (error) { - this.dispatchEvent(new Event('loadstart')); - this.status = 500; - this.response = error; - this.responseText = error.toString(); - this.responseURL = this._url; - this.readyState = 4; - this.dispatchEvent(new Event('error')); - this.dispatchEvent(new Event('loadend')); - } - }; + } + }; + } } // patch window.console on iOS and store original console fns const isIos = getPlatformId(win) === 'ios'; From 637db5728bac3b7a3cc3f046347d68e298311f91 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Tue, 13 Sep 2022 10:01:59 -0700 Subject: [PATCH 07/15] fix: angular zone.js race condition --- .../src/main/assets/native-bridge.js | 13 +++++------ core/native-bridge.ts | 23 +++++++++---------- .../Capacitor/assets/native-bridge.js | 13 +++++------ 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/android/capacitor/src/main/assets/native-bridge.js b/android/capacitor/src/main/assets/native-bridge.js index d1cabe44a..1171a51a3 100644 --- a/android/capacitor/src/main/assets/native-bridge.js +++ b/android/capacitor/src/main/assets/native-bridge.js @@ -368,6 +368,12 @@ const nativeBridge = (function (exports) { }; // XHR patch abort window.XMLHttpRequest.prototype.abort = function () { + this.readyState = 0; + this.dispatchEvent(new Event('abort')); + this.dispatchEvent(new Event('loadend')); + }; + // XHR patch open + window.XMLHttpRequest.prototype.open = function (method, url) { Object.defineProperties(this, { _headers: { value: {}, @@ -400,13 +406,6 @@ const nativeBridge = (function (exports) { writable: true, }, }); - this.readyState = 0; - this.dispatchEvent(new Event('abort')); - this.dispatchEvent(new Event('loadend')); - }; - // XHR patch open - window.XMLHttpRequest.prototype.open = function (method, url) { - this.abort(); addEventListeners.call(this); this._method = method; this._url = url; diff --git a/core/native-bridge.ts b/core/native-bridge.ts index bf632ddd7..3b3091ad9 100644 --- a/core/native-bridge.ts +++ b/core/native-bridge.ts @@ -438,7 +438,17 @@ const initBridge = (w: any): void => { }; // XHR patch abort - window.XMLHttpRequest.prototype.abort = function () { + window.XMLHttpRequest.prototype.abort = function () { + this.readyState = 0; + this.dispatchEvent(new Event('abort')); + this.dispatchEvent(new Event('loadend')); + }; + + // XHR patch open + window.XMLHttpRequest.prototype.open = function ( + method: string, + url: string, + ) { Object.defineProperties(this, { _headers: { value: {}, @@ -470,17 +480,6 @@ const initBridge = (w: any): void => { writable: true, }, }); - this.readyState = 0; - this.dispatchEvent(new Event('abort')); - this.dispatchEvent(new Event('loadend')); - }; - - // XHR patch open - window.XMLHttpRequest.prototype.open = function ( - method: string, - url: string, - ) { - this.abort(); addEventListeners.call(this); this._method = method; this._url = url; diff --git a/ios/Capacitor/Capacitor/assets/native-bridge.js b/ios/Capacitor/Capacitor/assets/native-bridge.js index d1cabe44a..1171a51a3 100644 --- a/ios/Capacitor/Capacitor/assets/native-bridge.js +++ b/ios/Capacitor/Capacitor/assets/native-bridge.js @@ -368,6 +368,12 @@ const nativeBridge = (function (exports) { }; // XHR patch abort window.XMLHttpRequest.prototype.abort = function () { + this.readyState = 0; + this.dispatchEvent(new Event('abort')); + this.dispatchEvent(new Event('loadend')); + }; + // XHR patch open + window.XMLHttpRequest.prototype.open = function (method, url) { Object.defineProperties(this, { _headers: { value: {}, @@ -400,13 +406,6 @@ const nativeBridge = (function (exports) { writable: true, }, }); - this.readyState = 0; - this.dispatchEvent(new Event('abort')); - this.dispatchEvent(new Event('loadend')); - }; - // XHR patch open - window.XMLHttpRequest.prototype.open = function (method, url) { - this.abort(); addEventListeners.call(this); this._method = method; this._url = url; From ccc8ac56df37cf7bca0fbe03b057236a96e5b538 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Tue, 13 Sep 2022 10:55:44 -0700 Subject: [PATCH 08/15] fix: default http method to GET --- .../java/com/getcapacitor/plugin/util/HttpRequestHandler.java | 2 +- ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift | 1 - ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java index 4d0267963..7627c7fd3 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java @@ -371,7 +371,7 @@ public static JSObject request(PluginCall call, String httpMethod) throws IOExce Boolean shouldEncode = call.getBoolean("shouldEncodeUrlParams", true); ResponseType responseType = ResponseType.parse(call.getString("responseType")); - String method = httpMethod != null ? httpMethod.toUpperCase() : call.getString("method", "").toUpperCase(); + String method = httpMethod != null ? httpMethod.toUpperCase() : call.getString("method", "GET").toUpperCase(); boolean isHttpMutate = method.equals("DELETE") || method.equals("PATCH") || method.equals("POST") || method.equals("PUT"); diff --git a/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift b/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift index 11ea449eb..9daf34a83 100644 --- a/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift +++ b/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift @@ -5,7 +5,6 @@ public class CAPHttpPlugin: CAPPlugin { @objc func http(_ call: CAPPluginCall, _ httpMethod: String?) { // Protect against bad values from JS before calling request guard let u = call.getString("url") else { return call.reject("Must provide a URL"); } - guard let _ = httpMethod ?? call.getString("method") else { return call.reject("Must provide an HTTP Method"); } guard var _ = URL(string: u) else { return call.reject("Invalid URL"); } do { diff --git a/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift b/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift index c26afb031..2acc4ae0f 100644 --- a/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift +++ b/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift @@ -123,7 +123,7 @@ class HttpRequestHandler { public static func request(_ call: CAPPluginCall, _ httpMethod: String?) throws { guard let urlString = call.getString("url") else { throw URLError(.badURL) } - guard let method = httpMethod ?? call.getString("method") else { throw URLError(.dataNotAllowed) } + let method = httpMethod ?? call.getString("method", "GET") let headers = (call.getObject("headers") ?? [:]) as! [String: String] let params = (call.getObject("params") ?? [:]) as! [String: Any] From b960a59c5e083c885763455e3d1928aa772992c9 Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Tue, 13 Sep 2022 11:11:48 -0700 Subject: [PATCH 09/15] fix: default cap http to opt-in --- android/capacitor/src/main/assets/native-bridge.js | 14 +++++++------- .../com/getcapacitor/plugin/CapacitorHttp.java | 4 ++-- core/native-bridge.ts | 14 +++++++------- .../Capacitor/WebViewDelegationHandler.swift | 2 +- ios/Capacitor/Capacitor/assets/native-bridge.js | 14 +++++++------- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/android/capacitor/src/main/assets/native-bridge.js b/android/capacitor/src/main/assets/native-bridge.js index 1171a51a3..4c09c3508 100644 --- a/android/capacitor/src/main/assets/native-bridge.js +++ b/android/capacitor/src/main/assets/native-bridge.js @@ -291,7 +291,7 @@ const nativeBridge = (function (exports) { send: window.XMLHttpRequest.prototype.send, setRequestHeader: window.XMLHttpRequest.prototype.setRequestHeader, }; - let doPatchHttp = true; + let doPatchHttp = false; // check if capacitor http is disabled before patching if (platform === 'ios') { // Use prompt to synchronously get capacitor http config. @@ -299,15 +299,15 @@ const nativeBridge = (function (exports) { const payload = { type: 'CapacitorHttp', }; - const isDisabled = prompt(JSON.stringify(payload)); - if (isDisabled === 'true') { - doPatchHttp = false; + const isEnabled = prompt(JSON.stringify(payload)); + if (isEnabled === 'true') { + doPatchHttp = true; } } else if (typeof win.CapacitorHttpAndroidInterface !== 'undefined') { - const isDisabled = win.CapacitorHttpAndroidInterface.isDisabled(); - if (isDisabled === true) { - doPatchHttp = false; + const isEnabled = win.CapacitorHttpAndroidInterface.isEnabled(); + if (isEnabled === true) { + doPatchHttp = true; } } if (doPatchHttp) { diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java index 02c14d387..eb9c624ac 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java @@ -44,9 +44,9 @@ public void run() { } @JavascriptInterface - public boolean isDisabled() { + public boolean isEnabled() { PluginConfig pluginConfig = getBridge().getConfig().getPluginConfiguration("CapacitorHttp"); - return pluginConfig.getBoolean("disabled", false); + return pluginConfig.getBoolean("enabled", false); } @PluginMethod diff --git a/core/native-bridge.ts b/core/native-bridge.ts index 3b3091ad9..50c4ba420 100644 --- a/core/native-bridge.ts +++ b/core/native-bridge.ts @@ -348,7 +348,7 @@ const initBridge = (w: any): void => { setRequestHeader: window.XMLHttpRequest.prototype.setRequestHeader, }; - let doPatchHttp = true; + let doPatchHttp = false; // check if capacitor http is disabled before patching if (platform === 'ios') { @@ -359,14 +359,14 @@ const initBridge = (w: any): void => { type: 'CapacitorHttp', }; - const isDisabled = prompt(JSON.stringify(payload)); - if (isDisabled === 'true') { - doPatchHttp = false; + const isEnabled = prompt(JSON.stringify(payload)); + if (isEnabled === 'true') { + doPatchHttp = true; } } else if (typeof win.CapacitorHttpAndroidInterface !== 'undefined') { - const isDisabled = win.CapacitorHttpAndroidInterface.isDisabled(); - if (isDisabled === true) { - doPatchHttp = false; + const isEnabled = win.CapacitorHttpAndroidInterface.isEnabled(); + if (isEnabled === true) { + doPatchHttp = true; } } diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift index ccbdf9fda..bbe1fffa0 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -256,7 +256,7 @@ internal class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDel return } else if type == "CapacitorHttp" { let pluginConfig = bridge!.config.getPluginConfig("CapacitorHttp") - completionHandler(String(pluginConfig.getBoolean("disabled", false))) + completionHandler(String(pluginConfig.getBoolean("enabled", false))) // Don't present prompt return } diff --git a/ios/Capacitor/Capacitor/assets/native-bridge.js b/ios/Capacitor/Capacitor/assets/native-bridge.js index 1171a51a3..4c09c3508 100644 --- a/ios/Capacitor/Capacitor/assets/native-bridge.js +++ b/ios/Capacitor/Capacitor/assets/native-bridge.js @@ -291,7 +291,7 @@ const nativeBridge = (function (exports) { send: window.XMLHttpRequest.prototype.send, setRequestHeader: window.XMLHttpRequest.prototype.setRequestHeader, }; - let doPatchHttp = true; + let doPatchHttp = false; // check if capacitor http is disabled before patching if (platform === 'ios') { // Use prompt to synchronously get capacitor http config. @@ -299,15 +299,15 @@ const nativeBridge = (function (exports) { const payload = { type: 'CapacitorHttp', }; - const isDisabled = prompt(JSON.stringify(payload)); - if (isDisabled === 'true') { - doPatchHttp = false; + const isEnabled = prompt(JSON.stringify(payload)); + if (isEnabled === 'true') { + doPatchHttp = true; } } else if (typeof win.CapacitorHttpAndroidInterface !== 'undefined') { - const isDisabled = win.CapacitorHttpAndroidInterface.isDisabled(); - if (isDisabled === true) { - doPatchHttp = false; + const isEnabled = win.CapacitorHttpAndroidInterface.isEnabled(); + if (isEnabled === true) { + doPatchHttp = true; } } if (doPatchHttp) { From 007c8513b73335bd76de204911491160052075ac Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Tue, 13 Sep 2022 11:14:49 -0700 Subject: [PATCH 10/15] chore: run fmt --- core/native-bridge.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/native-bridge.ts b/core/native-bridge.ts index 50c4ba420..c74d0728b 100644 --- a/core/native-bridge.ts +++ b/core/native-bridge.ts @@ -438,7 +438,7 @@ const initBridge = (w: any): void => { }; // XHR patch abort - window.XMLHttpRequest.prototype.abort = function () { + window.XMLHttpRequest.prototype.abort = function () { this.readyState = 0; this.dispatchEvent(new Event('abort')); this.dispatchEvent(new Event('loadend')); From 11e448a4b4a7a9e26d8014761d6329e34e6bfea0 Mon Sep 17 00:00:00 2001 From: ItsChaceD Date: Wed, 21 Sep 2022 07:52:25 -0700 Subject: [PATCH 11/15] fix: get response headers for XHR --- .../src/main/assets/native-bridge.js | 30 ++++++++++++++-- core/native-bridge.ts | 35 +++++++++++++++++-- core/src/core-plugins.ts | 28 +++++++++++++-- .../Capacitor/assets/native-bridge.js | 30 ++++++++++++++-- 4 files changed, 111 insertions(+), 12 deletions(-) diff --git a/android/capacitor/src/main/assets/native-bridge.js b/android/capacitor/src/main/assets/native-bridge.js index 4c09c3508..12bfb50fd 100644 --- a/android/capacitor/src/main/assets/native-bridge.js +++ b/android/capacitor/src/main/assets/native-bridge.js @@ -313,7 +313,8 @@ const nativeBridge = (function (exports) { if (doPatchHttp) { // fetch patch window.fetch = async (resource, options) => { - if (resource.toString().startsWith('data:')) { + if (resource.toString().startsWith('data:') || + resource.toString().startsWith('blob:')) { return win.CapacitorWebFetch(resource, options); } try { @@ -324,8 +325,11 @@ const nativeBridge = (function (exports) { data: (options === null || options === void 0 ? void 0 : options.body) ? options.body : undefined, headers: (options === null || options === void 0 ? void 0 : options.headers) ? options.headers : undefined, }); + const data = typeof nativeResponse.data === 'string' + ? nativeResponse.data + : JSON.stringify(nativeResponse.data); // intercept & parse response before returning - const response = new Response(JSON.stringify(nativeResponse.data), { + const response = new Response(data, { headers: nativeResponse.headers, status: nativeResponse.status, }); @@ -431,9 +435,13 @@ const nativeBridge = (function (exports) { // intercept & parse response before returning if (this.readyState == 2) { this.dispatchEvent(new Event('loadstart')); + this._headers = nativeResponse.headers; this.status = nativeResponse.status; this.response = nativeResponse.data; - this.responseText = JSON.stringify(nativeResponse.data); + this.responseText = + typeof nativeResponse.data === 'string' + ? nativeResponse.data + : JSON.stringify(nativeResponse.data); this.responseURL = nativeResponse.url; this.readyState = 4; this.dispatchEvent(new Event('load')); @@ -443,6 +451,7 @@ const nativeBridge = (function (exports) { .catch((error) => { this.dispatchEvent(new Event('loadstart')); this.status = error.status; + this._headers = error.headers; this.response = error.data; this.responseText = JSON.stringify(error.data); this.responseURL = error.url; @@ -454,6 +463,7 @@ const nativeBridge = (function (exports) { catch (error) { this.dispatchEvent(new Event('loadstart')); this.status = 500; + this._headers = {}; this.response = error; this.responseText = error.toString(); this.responseURL = this._url; @@ -462,6 +472,20 @@ const nativeBridge = (function (exports) { this.dispatchEvent(new Event('loadend')); } }; + // XHR patch getAllResponseHeaders + window.XMLHttpRequest.prototype.getAllResponseHeaders = function () { + let returnString = ''; + for (const key in this._headers) { + if (key != 'Set-Cookie') { + returnString += key + ': ' + this._headers[key] + '\r\n'; + } + } + return returnString; + }; + // XHR patch getResponseHeader + window.XMLHttpRequest.prototype.getResponseHeader = function (name) { + return this._headers[name]; + }; } } // patch window.console on iOS and store original console fns diff --git a/core/native-bridge.ts b/core/native-bridge.ts index c74d0728b..f0c7f46fc 100644 --- a/core/native-bridge.ts +++ b/core/native-bridge.ts @@ -376,7 +376,10 @@ const initBridge = (w: any): void => { resource: RequestInfo | URL, options?: RequestInit, ) => { - if (resource.toString().startsWith('data:')) { + if ( + resource.toString().startsWith('data:') || + resource.toString().startsWith('blob:') + ) { return win.CapacitorWebFetch(resource, options); } @@ -393,8 +396,12 @@ const initBridge = (w: any): void => { }, ); + const data = + typeof nativeResponse.data === 'string' + ? nativeResponse.data + : JSON.stringify(nativeResponse.data); // intercept & parse response before returning - const response = new Response(JSON.stringify(nativeResponse.data), { + const response = new Response(data, { headers: nativeResponse.headers, status: nativeResponse.status, }); @@ -513,9 +520,13 @@ const initBridge = (w: any): void => { // intercept & parse response before returning if (this.readyState == 2) { this.dispatchEvent(new Event('loadstart')); + this._headers = nativeResponse.headers; this.status = nativeResponse.status; this.response = nativeResponse.data; - this.responseText = JSON.stringify(nativeResponse.data); + this.responseText = + typeof nativeResponse.data === 'string' + ? nativeResponse.data + : JSON.stringify(nativeResponse.data); this.responseURL = nativeResponse.url; this.readyState = 4; this.dispatchEvent(new Event('load')); @@ -525,6 +536,7 @@ const initBridge = (w: any): void => { .catch((error: any) => { this.dispatchEvent(new Event('loadstart')); this.status = error.status; + this._headers = error.headers; this.response = error.data; this.responseText = JSON.stringify(error.data); this.responseURL = error.url; @@ -535,6 +547,7 @@ const initBridge = (w: any): void => { } catch (error) { this.dispatchEvent(new Event('loadstart')); this.status = 500; + this._headers = {}; this.response = error; this.responseText = error.toString(); this.responseURL = this._url; @@ -543,6 +556,22 @@ const initBridge = (w: any): void => { this.dispatchEvent(new Event('loadend')); } }; + + // XHR patch getAllResponseHeaders + window.XMLHttpRequest.prototype.getAllResponseHeaders = function () { + let returnString = ''; + for (const key in this._headers) { + if (key != 'Set-Cookie') { + returnString += key + ': ' + this._headers[key] + '\r\n'; + } + } + return returnString; + }; + + // XHR patch getResponseHeader + window.XMLHttpRequest.prototype.getResponseHeader = function (name) { + return this._headers[name]; + }; } } diff --git a/core/src/core-plugins.ts b/core/src/core-plugins.ts index 0f25fbe29..f708f917c 100644 --- a/core/src/core-plugins.ts +++ b/core/src/core-plugins.ts @@ -120,11 +120,11 @@ declare module '../../cli' { export interface PluginsConfig { CapacitorHttp?: { /** - * Disable CapacitorHttp from monkey patching the global fetch and XMLHttpRequest + * Enable CapacitorHttp to override the global fetch and XMLHttpRequest * * @default false */ - disabled?: boolean; + enabled?: boolean; }; } } @@ -197,6 +197,26 @@ export interface HttpResponse { // UTILITY FUNCTIONS +/** + * Read in a Blob value and return it as a base64 string + * @param blob The blob value to convert to a base64 string + */ +export const readBlobAsBase64 = async (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const base64String = reader.result as string; + // remove prefix "data:application/pdf;base64," + resolve( + base64String.indexOf(',') >= 0 + ? base64String.split(',')[1] + : base64String, + ); + }; + reader.onerror = (error: any) => reject(error); + reader.readAsDataURL(blob); + }); + /** * Normalize an HttpHeaders map by lowercasing all of the values * @param headers The HttpHeaders object to normalize @@ -331,10 +351,12 @@ export class CapacitorHttpPluginWeb } let data: any; + let blob: any; switch (responseType) { case 'arraybuffer': case 'blob': - //TODO: Add Blob Support + blob = await response.blob(); + data = await readBlobAsBase64(blob); break; case 'json': data = await response.json(); diff --git a/ios/Capacitor/Capacitor/assets/native-bridge.js b/ios/Capacitor/Capacitor/assets/native-bridge.js index 4c09c3508..12bfb50fd 100644 --- a/ios/Capacitor/Capacitor/assets/native-bridge.js +++ b/ios/Capacitor/Capacitor/assets/native-bridge.js @@ -313,7 +313,8 @@ const nativeBridge = (function (exports) { if (doPatchHttp) { // fetch patch window.fetch = async (resource, options) => { - if (resource.toString().startsWith('data:')) { + if (resource.toString().startsWith('data:') || + resource.toString().startsWith('blob:')) { return win.CapacitorWebFetch(resource, options); } try { @@ -324,8 +325,11 @@ const nativeBridge = (function (exports) { data: (options === null || options === void 0 ? void 0 : options.body) ? options.body : undefined, headers: (options === null || options === void 0 ? void 0 : options.headers) ? options.headers : undefined, }); + const data = typeof nativeResponse.data === 'string' + ? nativeResponse.data + : JSON.stringify(nativeResponse.data); // intercept & parse response before returning - const response = new Response(JSON.stringify(nativeResponse.data), { + const response = new Response(data, { headers: nativeResponse.headers, status: nativeResponse.status, }); @@ -431,9 +435,13 @@ const nativeBridge = (function (exports) { // intercept & parse response before returning if (this.readyState == 2) { this.dispatchEvent(new Event('loadstart')); + this._headers = nativeResponse.headers; this.status = nativeResponse.status; this.response = nativeResponse.data; - this.responseText = JSON.stringify(nativeResponse.data); + this.responseText = + typeof nativeResponse.data === 'string' + ? nativeResponse.data + : JSON.stringify(nativeResponse.data); this.responseURL = nativeResponse.url; this.readyState = 4; this.dispatchEvent(new Event('load')); @@ -443,6 +451,7 @@ const nativeBridge = (function (exports) { .catch((error) => { this.dispatchEvent(new Event('loadstart')); this.status = error.status; + this._headers = error.headers; this.response = error.data; this.responseText = JSON.stringify(error.data); this.responseURL = error.url; @@ -454,6 +463,7 @@ const nativeBridge = (function (exports) { catch (error) { this.dispatchEvent(new Event('loadstart')); this.status = 500; + this._headers = {}; this.response = error; this.responseText = error.toString(); this.responseURL = this._url; @@ -462,6 +472,20 @@ const nativeBridge = (function (exports) { this.dispatchEvent(new Event('loadend')); } }; + // XHR patch getAllResponseHeaders + window.XMLHttpRequest.prototype.getAllResponseHeaders = function () { + let returnString = ''; + for (const key in this._headers) { + if (key != 'Set-Cookie') { + returnString += key + ': ' + this._headers[key] + '\r\n'; + } + } + return returnString; + }; + // XHR patch getResponseHeader + window.XMLHttpRequest.prototype.getResponseHeader = function (name) { + return this._headers[name]; + }; } } // patch window.console on iOS and store original console fns From c90a01e250753d8d12c9f97da17b366cb6aaf974 Mon Sep 17 00:00:00 2001 From: ItsChaceD Date: Wed, 21 Sep 2022 08:14:56 -0700 Subject: [PATCH 12/15] chore(ios): swiftlint fixes --- ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift | 8 ++++---- .../Capacitor/Plugins/HttpRequestHandler.swift | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift b/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift index 9daf34a83..6ba1f69b5 100644 --- a/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift +++ b/ios/Capacitor/Capacitor/Plugins/CapacitorHttp.swift @@ -4,13 +4,13 @@ import Foundation public class CAPHttpPlugin: CAPPlugin { @objc func http(_ call: CAPPluginCall, _ httpMethod: String?) { // Protect against bad values from JS before calling request - guard let u = call.getString("url") else { return call.reject("Must provide a URL"); } - guard var _ = URL(string: u) else { return call.reject("Invalid URL"); } + guard let url = call.getString("url") else { return call.reject("Must provide a URL"); } + guard var _ = URL(string: url) else { return call.reject("Invalid URL"); } do { try HttpRequestHandler.request(call, httpMethod) - } catch let e { - call.reject(e.localizedDescription) + } catch let error { + call.reject(error.localizedDescription) } } diff --git a/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift b/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift index 2acc4ae0f..06b96ae31 100644 --- a/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift +++ b/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift @@ -50,10 +50,10 @@ class HttpRequestHandler { /// - urlString: The URL value to parse /// - Returns: self to continue chaining functions public func setUrl(_ urlString: String) throws -> CapacitorHttpRequestBuilder { - guard let u = URL(string: urlString) else { + guard let url = URL(string: urlString) else { throw URLError(.badURL) } - url = u + self.url = url return self } @@ -64,6 +64,7 @@ class HttpRequestHandler { public func setUrlParams(_ params: [String: Any]) -> CapacitorHttpRequestBuilder { if params.count != 0 { + // swiftlint:disable force_cast var cmps = URLComponents(url: url!, resolvingAgainstBaseURL: true) if cmps?.queryItems == nil { cmps?.queryItems = [] @@ -125,13 +126,14 @@ class HttpRequestHandler { guard let urlString = call.getString("url") else { throw URLError(.badURL) } let method = httpMethod ?? call.getString("method", "GET") + // swiftlint:disable force_cast let headers = (call.getObject("headers") ?? [:]) as! [String: String] - let params = (call.getObject("params") ?? [:]) as! [String: Any] + let params = (call.getObject("params") ?? [:]) as [String: Any] let responseType = call.getString("responseType") ?? "text" let connectTimeout = call.getDouble("connectTimeout") let readTimeout = call.getDouble("readTimeout") - let request = try! CapacitorHttpRequestBuilder() + let request = try CapacitorHttpRequestBuilder() .setUrl(urlString) .setMethod(method) .setUrlParams(params) From 959c4fdc4773ad7571238a5fbb8c8d6d6543a0a0 Mon Sep 17 00:00:00 2001 From: ItsChaceD Date: Wed, 21 Sep 2022 10:10:03 -0700 Subject: [PATCH 13/15] feat: add opt-in config for cookies --- .../src/main/assets/native-bridge.js | 89 +++++++++------ .../getcapacitor/plugin/CapacitorCookies.java | 7 ++ cli/src/declarations.ts | 28 +++++ core/native-bridge.ts | 101 +++++++++++------- core/src/core-plugins.ts | 15 --- .../Capacitor/WebViewDelegationHandler.swift | 5 + .../Capacitor/assets/native-bridge.js | 89 +++++++++------ 7 files changed, 213 insertions(+), 121 deletions(-) diff --git a/android/capacitor/src/main/assets/native-bridge.js b/android/capacitor/src/main/assets/native-bridge.js index 80ea9fa2e..dbed700c4 100644 --- a/android/capacitor/src/main/assets/native-bridge.js +++ b/android/capacitor/src/main/assets/native-bridge.js @@ -2,7 +2,7 @@ /*! Capacitor: https://capacitorjs.com/ - MIT License */ /* Generated File. Do not edit. */ -var nativeBridge = (function (exports) { +const nativeBridge = (function (exports) { 'use strict'; var ExceptionCode; @@ -279,36 +279,57 @@ var nativeBridge = (function (exports) { win.CapacitorCookiesDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') || Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie'); - Object.defineProperty(document, 'cookie', { - get: function () { - if (platform === 'ios') { - // Use prompt to synchronously get cookies. - // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 - const payload = { - type: 'CapacitorCookies', - }; - const res = prompt(JSON.stringify(payload)); - return res; - } - else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') { - return win.CapacitorCookiesAndroidInterface.getCookies(); - } - }, - set: function (val) { - const cookiePairs = val.split(';'); - for (const cookiePair of cookiePairs) { - const cookieKey = cookiePair.split('=')[0]; - const cookieValue = cookiePair.split('=')[1]; - if (null == cookieValue) { - continue; + let doPatchCookies = false; + // check if capacitor cookies is disabled before patching + if (platform === 'ios') { + // Use prompt to synchronously get capacitor cookies config. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + const payload = { + type: 'CapacitorCookies.isEnabled', + }; + const isCookiesEnabled = prompt(JSON.stringify(payload)); + if (isCookiesEnabled === 'true') { + doPatchCookies = true; + } + } + else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') { + const isCookiesEnabled = win.CapacitorCookiesAndroidInterface.isEnabled(); + if (isCookiesEnabled === true) { + doPatchCookies = true; + } + } + if (doPatchCookies) { + Object.defineProperty(document, 'cookie', { + get: function () { + if (platform === 'ios') { + // Use prompt to synchronously get cookies. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + const payload = { + type: 'CapacitorCookies', + }; + const res = prompt(JSON.stringify(payload)); + return res; } - cap.toNative('CapacitorCookies', 'setCookie', { - key: cookieKey, - value: decode(cookieValue), - }); - } - }, - }); + else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') { + return win.CapacitorCookiesAndroidInterface.getCookies(); + } + }, + set: function (val) { + const cookiePairs = val.split(';'); + for (const cookiePair of cookiePairs) { + const cookieKey = cookiePair.split('=')[0]; + const cookieValue = cookiePair.split('=')[1]; + if (null == cookieValue) { + continue; + } + cap.toNative('CapacitorCookies', 'setCookie', { + key: cookieKey, + value: decode(cookieValue), + }); + } + }, + }); + } // patch fetch / XHR on Android/iOS // store original fetch & XHR functions win.CapacitorWebFetch = window.fetch; @@ -326,14 +347,14 @@ var nativeBridge = (function (exports) { const payload = { type: 'CapacitorHttp', }; - const isEnabled = prompt(JSON.stringify(payload)); - if (isEnabled === 'true') { + const isHttpEnabled = prompt(JSON.stringify(payload)); + if (isHttpEnabled === 'true') { doPatchHttp = true; } } else if (typeof win.CapacitorHttpAndroidInterface !== 'undefined') { - const isEnabled = win.CapacitorHttpAndroidInterface.isEnabled(); - if (isEnabled === true) { + const isHttpEnabled = win.CapacitorHttpAndroidInterface.isEnabled(); + if (isHttpEnabled === true) { doPatchHttp = true; } } diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java index e980a339c..4d3175980 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java @@ -9,6 +9,7 @@ import com.getcapacitor.JSObject; import com.getcapacitor.Plugin; import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginConfig; import com.getcapacitor.PluginMethod; import com.getcapacitor.annotation.CapacitorPlugin; import java.net.CookieHandler; @@ -28,6 +29,12 @@ public void load() { super.load(); } + @JavascriptInterface + public boolean isEnabled() { + PluginConfig pluginConfig = getBridge().getConfig().getPluginConfiguration("CapacitorCookies"); + return pluginConfig.getBoolean("enabled", false); + } + /** * Helper function for getting the serverUrl from the Capacitor Config. Returns an empty * string if it is invalid and will auto-reject through {@code call} diff --git a/cli/src/declarations.ts b/cli/src/declarations.ts index 697036b67..560467c82 100644 --- a/cli/src/declarations.ts +++ b/cli/src/declarations.ts @@ -562,4 +562,32 @@ export interface PluginsConfig { * @since 4.2.0 */ LiveUpdates?: LiveUpdateConfig; + + /** + * Capacitor Cookies plugin configuration + * + * @since 4.2.0 + */ + CapacitorCookies?: { + /** + * Enable CapacitorCookies to override the global `document.cookie` on native. + * + * @default false + */ + enabled?: boolean; + }; + + /** + * Capacitor Http plugin configuration + * + * @since 4.2.0 + */ + CapacitorHttp?: { + /** + * Enable CapacitorHttp to override the global `fetch` and `XMLHttpRequest` on native. + * + * @default false + */ + enabled?: boolean; + }; } diff --git a/core/native-bridge.ts b/core/native-bridge.ts index b1deffa2b..fdc6753dd 100644 --- a/core/native-bridge.ts +++ b/core/native-bridge.ts @@ -303,41 +303,66 @@ const initBridge = (w: any): void => { Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') || Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie'); - Object.defineProperty(document, 'cookie', { - get: function () { - if (platform === 'ios') { - // Use prompt to synchronously get cookies. - // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 - - const payload = { - type: 'CapacitorCookies', - }; - - const res = prompt(JSON.stringify(payload)); - return res; - } else if ( - typeof win.CapacitorCookiesAndroidInterface !== 'undefined' - ) { - return win.CapacitorCookiesAndroidInterface.getCookies(); - } - }, - set: function (val) { - const cookiePairs = val.split(';'); - for (const cookiePair of cookiePairs) { - const cookieKey = cookiePair.split('=')[0]; - const cookieValue = cookiePair.split('=')[1]; - - if (null == cookieValue) { - continue; - } + let doPatchCookies = false; - cap.toNative('CapacitorCookies', 'setCookie', { - key: cookieKey, - value: decode(cookieValue), - }); - } - }, - }); + // check if capacitor cookies is disabled before patching + if (platform === 'ios') { + // Use prompt to synchronously get capacitor cookies config. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + + const payload = { + type: 'CapacitorCookies.isEnabled', + }; + + const isCookiesEnabled = prompt(JSON.stringify(payload)); + if (isCookiesEnabled === 'true') { + doPatchCookies = true; + } + } else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') { + const isCookiesEnabled = + win.CapacitorCookiesAndroidInterface.isEnabled(); + if (isCookiesEnabled === true) { + doPatchCookies = true; + } + } + + if (doPatchCookies) { + Object.defineProperty(document, 'cookie', { + get: function () { + if (platform === 'ios') { + // Use prompt to synchronously get cookies. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + + const payload = { + type: 'CapacitorCookies', + }; + + const res = prompt(JSON.stringify(payload)); + return res; + } else if ( + typeof win.CapacitorCookiesAndroidInterface !== 'undefined' + ) { + return win.CapacitorCookiesAndroidInterface.getCookies(); + } + }, + set: function (val) { + const cookiePairs = val.split(';'); + for (const cookiePair of cookiePairs) { + const cookieKey = cookiePair.split('=')[0]; + const cookieValue = cookiePair.split('=')[1]; + + if (null == cookieValue) { + continue; + } + + cap.toNative('CapacitorCookies', 'setCookie', { + key: cookieKey, + value: decode(cookieValue), + }); + } + }, + }); + } // patch fetch / XHR on Android/iOS // store original fetch & XHR functions @@ -360,13 +385,13 @@ const initBridge = (w: any): void => { type: 'CapacitorHttp', }; - const isEnabled = prompt(JSON.stringify(payload)); - if (isEnabled === 'true') { + const isHttpEnabled = prompt(JSON.stringify(payload)); + if (isHttpEnabled === 'true') { doPatchHttp = true; } } else if (typeof win.CapacitorHttpAndroidInterface !== 'undefined') { - const isEnabled = win.CapacitorHttpAndroidInterface.isEnabled(); - if (isEnabled === true) { + const isHttpEnabled = win.CapacitorHttpAndroidInterface.isEnabled(); + if (isHttpEnabled === true) { doPatchHttp = true; } } diff --git a/core/src/core-plugins.ts b/core/src/core-plugins.ts index f708f917c..bd0d5fa7a 100644 --- a/core/src/core-plugins.ts +++ b/core/src/core-plugins.ts @@ -1,5 +1,3 @@ -/// - import type { Plugin } from './definitions'; import { registerPlugin } from './global'; import { WebPlugin } from './web-plugin'; @@ -116,19 +114,6 @@ export const CapacitorCookies = registerPlugin( /******** END COOKIES PLUGIN ********/ /******** HTTP PLUGIN ********/ -declare module '../../cli' { - export interface PluginsConfig { - CapacitorHttp?: { - /** - * Enable CapacitorHttp to override the global fetch and XMLHttpRequest - * - * @default false - */ - enabled?: boolean; - }; - } -} - export interface CapacitorHttpPlugin { request(options: HttpOptions): Promise; get(options: HttpOptions): Promise; diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift index bbe1fffa0..cff1b8d7c 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -254,6 +254,11 @@ internal class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDel completionHandler(CapacitorCookieManager(bridge!.config).getCookies()) // Don't present prompt return + } else if type == "CapacitorCookies.isEnabled" { + let pluginConfig = bridge!.config.getPluginConfig("CapacitorCookies") + completionHandler(String(pluginConfig.getBoolean("enabled", false))) + // Don't present prompt + return } else if type == "CapacitorHttp" { let pluginConfig = bridge!.config.getPluginConfig("CapacitorHttp") completionHandler(String(pluginConfig.getBoolean("enabled", false))) diff --git a/ios/Capacitor/Capacitor/assets/native-bridge.js b/ios/Capacitor/Capacitor/assets/native-bridge.js index 80ea9fa2e..dbed700c4 100644 --- a/ios/Capacitor/Capacitor/assets/native-bridge.js +++ b/ios/Capacitor/Capacitor/assets/native-bridge.js @@ -2,7 +2,7 @@ /*! Capacitor: https://capacitorjs.com/ - MIT License */ /* Generated File. Do not edit. */ -var nativeBridge = (function (exports) { +const nativeBridge = (function (exports) { 'use strict'; var ExceptionCode; @@ -279,36 +279,57 @@ var nativeBridge = (function (exports) { win.CapacitorCookiesDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') || Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie'); - Object.defineProperty(document, 'cookie', { - get: function () { - if (platform === 'ios') { - // Use prompt to synchronously get cookies. - // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 - const payload = { - type: 'CapacitorCookies', - }; - const res = prompt(JSON.stringify(payload)); - return res; - } - else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') { - return win.CapacitorCookiesAndroidInterface.getCookies(); - } - }, - set: function (val) { - const cookiePairs = val.split(';'); - for (const cookiePair of cookiePairs) { - const cookieKey = cookiePair.split('=')[0]; - const cookieValue = cookiePair.split('=')[1]; - if (null == cookieValue) { - continue; + let doPatchCookies = false; + // check if capacitor cookies is disabled before patching + if (platform === 'ios') { + // Use prompt to synchronously get capacitor cookies config. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + const payload = { + type: 'CapacitorCookies.isEnabled', + }; + const isCookiesEnabled = prompt(JSON.stringify(payload)); + if (isCookiesEnabled === 'true') { + doPatchCookies = true; + } + } + else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') { + const isCookiesEnabled = win.CapacitorCookiesAndroidInterface.isEnabled(); + if (isCookiesEnabled === true) { + doPatchCookies = true; + } + } + if (doPatchCookies) { + Object.defineProperty(document, 'cookie', { + get: function () { + if (platform === 'ios') { + // Use prompt to synchronously get cookies. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + const payload = { + type: 'CapacitorCookies', + }; + const res = prompt(JSON.stringify(payload)); + return res; } - cap.toNative('CapacitorCookies', 'setCookie', { - key: cookieKey, - value: decode(cookieValue), - }); - } - }, - }); + else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') { + return win.CapacitorCookiesAndroidInterface.getCookies(); + } + }, + set: function (val) { + const cookiePairs = val.split(';'); + for (const cookiePair of cookiePairs) { + const cookieKey = cookiePair.split('=')[0]; + const cookieValue = cookiePair.split('=')[1]; + if (null == cookieValue) { + continue; + } + cap.toNative('CapacitorCookies', 'setCookie', { + key: cookieKey, + value: decode(cookieValue), + }); + } + }, + }); + } // patch fetch / XHR on Android/iOS // store original fetch & XHR functions win.CapacitorWebFetch = window.fetch; @@ -326,14 +347,14 @@ var nativeBridge = (function (exports) { const payload = { type: 'CapacitorHttp', }; - const isEnabled = prompt(JSON.stringify(payload)); - if (isEnabled === 'true') { + const isHttpEnabled = prompt(JSON.stringify(payload)); + if (isHttpEnabled === 'true') { doPatchHttp = true; } } else if (typeof win.CapacitorHttpAndroidInterface !== 'undefined') { - const isEnabled = win.CapacitorHttpAndroidInterface.isEnabled(); - if (isEnabled === true) { + const isHttpEnabled = win.CapacitorHttpAndroidInterface.isEnabled(); + if (isHttpEnabled === true) { doPatchHttp = true; } } From 7ce294c27b03602ad982a1ea7e301451b4f7609b Mon Sep 17 00:00:00 2001 From: ItsChaceD Date: Wed, 21 Sep 2022 11:12:41 -0700 Subject: [PATCH 14/15] fix(ci): verify tests --- .../getcapacitor/plugin/util/HttpRequestHandler.java | 3 ++- ios/Capacitor/Capacitor.xcodeproj/project.pbxproj | 12 ++++++++++++ ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m | 8 ++++---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java index 7627c7fd3..97b065a1e 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java @@ -18,6 +18,7 @@ import java.net.URL; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; import org.json.JSONArray; import org.json.JSONException; @@ -371,7 +372,7 @@ public static JSObject request(PluginCall call, String httpMethod) throws IOExce Boolean shouldEncode = call.getBoolean("shouldEncodeUrlParams", true); ResponseType responseType = ResponseType.parse(call.getString("responseType")); - String method = httpMethod != null ? httpMethod.toUpperCase() : call.getString("method", "GET").toUpperCase(); + String method = httpMethod != null ? httpMethod.toUpperCase(Locale.ROOT) : call.getString("method", "GET").toUpperCase(Locale.ROOT); boolean isHttpMutate = method.equals("DELETE") || method.equals("PATCH") || method.equals("POST") || method.equals("PUT"); diff --git a/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj b/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj index 1cc058cfa..825395624 100644 --- a/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj +++ b/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj @@ -82,6 +82,9 @@ 62FABD1A25AE5C01007B3814 /* Array+Capacitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FABD1925AE5C01007B3814 /* Array+Capacitor.swift */; }; 62FABD2325AE60BA007B3814 /* BridgedTypesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 62FABD2225AE60BA007B3814 /* BridgedTypesTests.m */; }; 62FABD2B25AE6182007B3814 /* BridgedTypesHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FABD2A25AE6182007B3814 /* BridgedTypesHelper.swift */; }; + A327E6B628DB8B2900CA8B0A /* HttpRequestHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A327E6B228DB8B2800CA8B0A /* HttpRequestHandler.swift */; }; + A327E6B728DB8B2900CA8B0A /* CapacitorHttp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A327E6B428DB8B2900CA8B0A /* CapacitorHttp.swift */; }; + A327E6B828DB8B2900CA8B0A /* CapacitorUrlRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A327E6B528DB8B2900CA8B0A /* CapacitorUrlRequest.swift */; }; A38C3D7728484E76004B3680 /* CapacitorCookies.swift in Sources */ = {isa = PBXBuildFile; fileRef = A38C3D7628484E76004B3680 /* CapacitorCookies.swift */; }; A38C3D7B2848BE6F004B3680 /* CapacitorCookieManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A38C3D7A2848BE6F004B3680 /* CapacitorCookieManager.swift */; }; A71289E627F380A500DADDF3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71289E527F380A500DADDF3 /* Router.swift */; }; @@ -215,6 +218,9 @@ 62FABD1925AE5C01007B3814 /* Array+Capacitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Capacitor.swift"; sourceTree = ""; }; 62FABD2225AE60BA007B3814 /* BridgedTypesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BridgedTypesTests.m; sourceTree = ""; }; 62FABD2A25AE6182007B3814 /* BridgedTypesHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgedTypesHelper.swift; sourceTree = ""; }; + A327E6B228DB8B2800CA8B0A /* HttpRequestHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HttpRequestHandler.swift; sourceTree = ""; }; + A327E6B428DB8B2900CA8B0A /* CapacitorHttp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CapacitorHttp.swift; sourceTree = ""; }; + A327E6B528DB8B2900CA8B0A /* CapacitorUrlRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CapacitorUrlRequest.swift; sourceTree = ""; }; A38C3D7628484E76004B3680 /* CapacitorCookies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapacitorCookies.swift; sourceTree = ""; }; A38C3D7A2848BE6F004B3680 /* CapacitorCookieManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapacitorCookieManager.swift; sourceTree = ""; }; A71289E527F380A500DADDF3 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; @@ -363,6 +369,9 @@ 62959AEA2524DA7700A3D7F1 /* Plugins */ = { isa = PBXGroup; children = ( + A327E6B428DB8B2900CA8B0A /* CapacitorHttp.swift */, + A327E6B528DB8B2900CA8B0A /* CapacitorUrlRequest.swift */, + A327E6B228DB8B2800CA8B0A /* HttpRequestHandler.swift */, 62959AEF2524DA7700A3D7F1 /* Console.swift */, 62959AFD2524DA7700A3D7F1 /* DefaultPlugins.m */, 62959AF32524DA7700A3D7F1 /* WebView.swift */, @@ -591,6 +600,7 @@ 55F6736A26C371E6001E7AB9 /* CAPWebView.swift in Sources */, A38C3D7728484E76004B3680 /* CapacitorCookies.swift in Sources */, A71289E627F380A500DADDF3 /* Router.swift in Sources */, + A327E6B828DB8B2900CA8B0A /* CapacitorUrlRequest.swift in Sources */, 62959B362524DA7800A3D7F1 /* CAPBridgeViewController.swift in Sources */, 621ECCB72542045900D3D615 /* CAPBridgedJSTypes.m in Sources */, 62959B402524DA7800A3D7F1 /* TmpViewController.swift in Sources */, @@ -617,12 +627,14 @@ 62959B1A2524DA7800A3D7F1 /* CAPPluginCall.swift in Sources */, 62959B302524DA7800A3D7F1 /* UIStatusBarManager+CAPHandleTapAction.m in Sources */, 62959B392524DA7800A3D7F1 /* CapacitorExtension.swift in Sources */, + A327E6B628DB8B2900CA8B0A /* HttpRequestHandler.swift in Sources */, 62959B422524DA7800A3D7F1 /* DocLinks.swift in Sources */, 62FABD1A25AE5C01007B3814 /* Array+Capacitor.swift in Sources */, 62959B172524DA7800A3D7F1 /* JSExport.swift in Sources */, 373A69C1255C9360000A6F44 /* NotificationHandlerProtocol.swift in Sources */, 0F83E885285A332E006C43CB /* AppUUID.swift in Sources */, 625AF1ED258963C700869675 /* WebViewAssetHandler.swift in Sources */, + A327E6B728DB8B2900CA8B0A /* CapacitorHttp.swift in Sources */, 0F8F33B327DA980A003F49D6 /* PluginConfig.swift in Sources */, 62959B3C2524DA7800A3D7F1 /* CAPBridgeDelegate.swift in Sources */, 62959B2F2524DA7800A3D7F1 /* DefaultPlugins.m in Sources */, diff --git a/ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m b/ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m index d98716a8b..3f9873714 100644 --- a/ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m +++ b/ios/Capacitor/Capacitor/Plugins/DefaultPlugins.m @@ -9,10 +9,6 @@ CAP_PLUGIN_METHOD(clearAllCookies, CAPPluginReturnPromise); ) -CAP_PLUGIN(CAPConsolePlugin, "Console", - CAP_PLUGIN_METHOD(log, CAPPluginReturnNone); -) - CAP_PLUGIN(CAPHttpPlugin, "CapacitorHttp", CAP_PLUGIN_METHOD(request, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(get, CAPPluginReturnPromise); @@ -22,6 +18,10 @@ CAP_PLUGIN_METHOD(delete, CAPPluginReturnPromise); ) +CAP_PLUGIN(CAPConsolePlugin, "Console", + CAP_PLUGIN_METHOD(log, CAPPluginReturnNone); +) + CAP_PLUGIN(CAPWebViewPlugin, "WebView", CAP_PLUGIN_METHOD(setServerBasePath, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getServerBasePath, CAPPluginReturnPromise); From d679672914e89bce2220e9a32d146395f4491acf Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Wed, 21 Sep 2022 11:57:57 -0700 Subject: [PATCH 15/15] Update declarations.ts --- cli/src/declarations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/declarations.ts b/cli/src/declarations.ts index 560467c82..8bdbcf225 100644 --- a/cli/src/declarations.ts +++ b/cli/src/declarations.ts @@ -566,7 +566,7 @@ export interface PluginsConfig { /** * Capacitor Cookies plugin configuration * - * @since 4.2.0 + * @since 4.3.0 */ CapacitorCookies?: { /** @@ -580,7 +580,7 @@ export interface PluginsConfig { /** * Capacitor Http plugin configuration * - * @since 4.2.0 + * @since 4.3.0 */ CapacitorHttp?: { /**