Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(android): enhance initialization scripts #1076

Merged
merged 1 commit into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/enhance-init-scripts-android.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"wry": patch
---

Enhance initalization script implementation on Android supporting any kind of URL.
19 changes: 14 additions & 5 deletions src/android/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ macro_rules! android_binding {
$package,
RustWebViewClient,
handleRequest,
[JObject],
[JObject, jboolean],
jobject
);
android_fn!(
Expand Down Expand Up @@ -95,7 +95,11 @@ macro_rules! android_binding {
}};
}

fn handle_request(env: &mut JNIEnv, request: JObject) -> JniResult<jobject> {
fn handle_request(
env: &mut JNIEnv,
request: JObject,
is_document_start_script_enabled: jboolean,
) -> JniResult<jobject> {
if let Some(handler) = REQUEST_HANDLER.get() {
let mut request_builder = Request::builder();

Expand Down Expand Up @@ -146,7 +150,7 @@ fn handle_request(env: &mut JNIEnv, request: JObject) -> JniResult<jobject> {
}
};

let response = (handler.handler)(final_request);
let response = (handler.handler)(final_request, is_document_start_script_enabled != 0);
if let Some(response) = response {
let status = response.status();
let status_code = status.as_u16() as i32;
Expand Down Expand Up @@ -224,8 +228,13 @@ fn handle_request(env: &mut JNIEnv, request: JObject) -> JniResult<jobject> {
}

#[allow(non_snake_case)]
pub unsafe fn handleRequest(mut env: JNIEnv, _: JClass, request: JObject) -> jobject {
match handle_request(&mut env, request) {
pub unsafe fn handleRequest(
mut env: JNIEnv,
_: JClass,
request: JObject,
is_document_start_script_enabled: jboolean,
) -> jobject {
match handle_request(&mut env, request, is_document_start_script_enabled) {
Ok(response) => response,
Err(e) => {
log::warn!("Failed to handle request: {}", e);
Expand Down
18 changes: 17 additions & 1 deletion src/android/kotlin/RustWebView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,34 @@

package {{package}}

import android.annotation.SuppressLint
import android.webkit.*
import android.content.Context
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import kotlin.collections.Map

class RustWebView(context: Context): WebView(context) {
@SuppressLint("RestrictedApi")
class RustWebView(context: Context, val initScripts: Array<String>): WebView(context) {
val isDocumentStartScriptEnabled: Boolean

init {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.setGeolocationEnabled(true)
settings.databaseEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.javaScriptCanOpenWindowsAutomatically = true

if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
isDocumentStartScriptEnabled = true
for (script in initScripts) {
WebViewCompat.addDocumentStartJavaScript(this, script, setOf("*"));
}
} else {
isDocumentStartScriptEnabled = false
}

{{class-init}}
}

Expand Down
18 changes: 14 additions & 4 deletions src/android/kotlin/RustWebViewClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import android.graphics.Bitmap
import androidx.webkit.WebViewAssetLoader

class RustWebViewClient(context: Context): WebViewClient() {
private val interceptedState = mutableMapOf<String, Boolean>()

private val assetLoader = WebViewAssetLoader.Builder()
.setDomain(assetLoaderDomain())
.addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(context))
Expand All @@ -22,7 +24,9 @@ class RustWebViewClient(context: Context): WebViewClient() {
return if (withAssetLoader()) {
assetLoader.shouldInterceptRequest(request.url)
} else {
handleRequest(request)
val response = handleRequest(request, (view as RustWebView).isDocumentStartScriptEnabled)
interceptedState[request.url.toString()] = response != null
return response
}
}

Expand All @@ -33,11 +37,17 @@ class RustWebViewClient(context: Context): WebViewClient() {
return shouldOverride(request.url.toString())
}

override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?): Unit {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
if (interceptedState[url] == false) {
val webView = view as RustWebView
for (script in webView.initScripts) {
view.evaluateJavascript(script, null)
}
}
return onPageLoading(url)
}

override fun onPageFinished(view: WebView, url: String): Unit {
override fun onPageFinished(view: WebView, url: String) {
return onPageLoaded(url)
}

Expand All @@ -50,7 +60,7 @@ class RustWebViewClient(context: Context): WebViewClient() {

private external fun assetLoaderDomain(): String
private external fun withAssetLoader(): Boolean
private external fun handleRequest(request: WebResourceRequest): WebResourceResponse?
private external fun handleRequest(request: WebResourceRequest, isDocumentStartScriptEnabled: Boolean): WebResourceResponse?
private external fun shouldOverride(url: String): Boolean
private external fun onPageLoading(url: String)
private external fun onPageLoaded(url: String)
Expand Down
21 changes: 19 additions & 2 deletions src/android/main_pipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,24 @@ impl<'a> MainPipe<'a> {
on_webview_created,
autoplay,
user_agent,
initialization_scripts,
..
} = attrs;

let string_class = self.env.find_class("java/lang/String")?;
let initialization_scripts_array = self.env.new_object_array(
initialization_scripts.len() as i32,
string_class,
self.env.new_string("")?,
)?;
for (i, script) in initialization_scripts.into_iter().enumerate() {
self.env.set_object_array_element(
&initialization_scripts_array,
i as i32,
self.env.new_string(script)?,
)?;
}

// Create webview
let rust_webview_class = find_class(
&mut self.env,
Expand All @@ -62,8 +78,8 @@ impl<'a> MainPipe<'a> {
)?;
let webview = self.env.new_object(
&rust_webview_class,
"(Landroid/content/Context;)V",
&[activity.into()],
"(Landroid/content/Context;[Ljava/lang/String;)V",
&[activity.into(), (&initialization_scripts_array).into()],
)?;

// set media autoplay
Expand Down Expand Up @@ -344,4 +360,5 @@ pub(crate) struct CreateWebViewAttributes {
pub autoplay: bool,
pub on_webview_created: Option<Box<dyn Fn(super::Context) -> JniResult<()> + Send>>,
pub user_agent: Option<String>,
pub initialization_scripts: Vec<String>,
}
145 changes: 78 additions & 67 deletions src/android/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ macro_rules! define_static_handlers {

define_static_handlers! {
IPC = UnsafeIpc { handler: Box<dyn Fn(String)> };
REQUEST_HANDLER = UnsafeRequestHandler { handler: Box<dyn Fn(Request<Vec<u8>>) -> Option<HttpResponse<Cow<'static, [u8]>>>> };
REQUEST_HANDLER = UnsafeRequestHandler { handler: Box<dyn Fn(Request<Vec<u8>>, bool) -> Option<HttpResponse<Cow<'static, [u8]>>>> };
TITLE_CHANGE_HANDLER = UnsafeTitleHandler { handler: Box<dyn Fn(String)> };
URL_LOADING_OVERRIDE = UnsafeUrlLoadingOverride { handler: Box<dyn Fn(String) -> bool> };
ON_LOAD_HANDLER = UnsafeOnPageLoadHandler { handler: Box<dyn Fn(PageLoadEvent, String)> };
Expand Down Expand Up @@ -179,6 +179,7 @@ impl InnerWebView {
on_webview_created,
autoplay,
user_agent,
initialization_scripts: initialization_scripts.clone(),
}));

WITH_ASSET_LOADER.get_or_init(move || with_asset_loader);
Expand All @@ -187,77 +188,87 @@ impl InnerWebView {
}

REQUEST_HANDLER.get_or_init(move || {
UnsafeRequestHandler::new(Box::new(move |mut request| {
if let Some(custom_protocol) = custom_protocols.iter().find(|(name, _)| {
request
.uri()
.to_string()
.starts_with(&format!("{custom_protocol_scheme}://{}.", name))
}) {
*request.uri_mut() = request
.uri()
.to_string()
.replace(
&format!("{custom_protocol_scheme}://{}.", custom_protocol.0),
&format!("{}://", custom_protocol.0),
)
.parse()
.unwrap();

let (tx, rx) = channel();
let initialization_scripts = initialization_scripts.clone();
let responder: Box<dyn FnOnce(HttpResponse<Cow<'static, [u8]>>)> =
Box::new(move |mut response| {
let should_inject_scripts = response
.headers()
.get(CONTENT_TYPE)
// Content-Type must begin with the media type, but is case-insensitive.
// It may also be followed by any number of semicolon-delimited key value pairs.
// We don't care about these here.
// source: https://httpwg.org/specs/rfc9110.html#rfc.section.8.3.1
.and_then(|content_type| content_type.to_str().ok())
.map(|content_type_str| content_type_str.to_lowercase().starts_with("text/html"))
.unwrap_or_default();

if should_inject_scripts && !initialization_scripts.is_empty() {
let mut document =
kuchiki::parse_html().one(String::from_utf8_lossy(response.body()).into_owned());
let csp = response.headers_mut().get_mut(CONTENT_SECURITY_POLICY);
let mut hashes = Vec::new();
with_html_head(&mut document, |head| {
// iterate in reverse order since we are prepending each script to the head tag
for script in initialization_scripts.iter().rev() {
let script_el =
NodeRef::new_element(QualName::new(None, ns!(html), "script".into()), None);
script_el.append(NodeRef::new_text(script));
head.prepend(script_el);
if csp.is_some() {
hashes.push(hash_script(script));
UnsafeRequestHandler::new(Box::new(
move |mut request, is_document_start_script_enabled| {
if let Some(custom_protocol) = custom_protocols.iter().find(|(name, _)| {
request
.uri()
.to_string()
.starts_with(&format!("{custom_protocol_scheme}://{}.", name))
}) {
*request.uri_mut() = request
.uri()
.to_string()
.replace(
&format!("{custom_protocol_scheme}://{}.", custom_protocol.0),
&format!("{}://", custom_protocol.0),
)
.parse()
.unwrap();

let (tx, rx) = channel();
let initialization_scripts = initialization_scripts.clone();
let responder: Box<dyn FnOnce(HttpResponse<Cow<'static, [u8]>>)> =
Box::new(move |mut response| {
if !is_document_start_script_enabled {
log::info!("`addDocumentStartJavaScript` is not supported; injecting initialization scripts via custom protocol handler");
let should_inject_scripts = response
.headers()
.get(CONTENT_TYPE)
// Content-Type must begin with the media type, but is case-insensitive.
// It may also be followed by any number of semicolon-delimited key value pairs.
// We don't care about these here.
// source: https://httpwg.org/specs/rfc9110.html#rfc.section.8.3.1
.and_then(|content_type| content_type.to_str().ok())
.map(|content_type_str| {
content_type_str.to_lowercase().starts_with("text/html")
})
.unwrap_or_default();

if should_inject_scripts && !initialization_scripts.is_empty() {
let mut document = kuchiki::parse_html()
.one(String::from_utf8_lossy(response.body()).into_owned());
let csp = response.headers_mut().get_mut(CONTENT_SECURITY_POLICY);
let mut hashes = Vec::new();
with_html_head(&mut document, |head| {
// iterate in reverse order since we are prepending each script to the head tag
for script in initialization_scripts.iter().rev() {
let script_el = NodeRef::new_element(
QualName::new(None, ns!(html), "script".into()),
None,
);
script_el.append(NodeRef::new_text(script));
head.prepend(script_el);
if csp.is_some() {
hashes.push(hash_script(script));
}
}
});

if let Some(csp) = csp {
let csp_string = csp.to_str().unwrap().to_string();
let csp_string = if csp_string.contains("script-src") {
csp_string
.replace("script-src", &format!("script-src {}", hashes.join(" ")))
} else {
format!("{} script-src {}", csp_string, hashes.join(" "))
};
*csp = HeaderValue::from_str(&csp_string).unwrap();
}

*response.body_mut() = document.to_string().into_bytes().into();
}
});

if let Some(csp) = csp {
let csp_string = csp.to_str().unwrap().to_string();
let csp_string = if csp_string.contains("script-src") {
csp_string.replace("script-src", &format!("script-src {}", hashes.join(" ")))
} else {
format!("{} script-src {}", csp_string, hashes.join(" "))
};
*csp = HeaderValue::from_str(&csp_string).unwrap();
}

*response.body_mut() = document.to_string().into_bytes().into();
}

tx.send(response).unwrap();
});
tx.send(response).unwrap();
});

(custom_protocol.1)(request, RequestAsyncResponder { responder });
return Some(rx.recv().unwrap());
}
None
}))
(custom_protocol.1)(request, RequestAsyncResponder { responder });
return Some(rx.recv().unwrap());
}
None
},
))
});

if let Some(i) = ipc_handler {
Expand Down
8 changes: 6 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -675,8 +675,12 @@ impl<'a> WebViewBuilder<'a> {
///
/// ## Platform-specific
///
/// - **Android:** The Android WebView does not provide an API for initialization scripts,
/// so we prepend them to each HTML head. They are only implemented on custom protocol URLs.
/// - **Android:** When [addDocumentStartJavaScript] is not supported,
/// we prepend them to each HTML head (implementation only supported on custom protocol URLs).
/// For remote URLs, we use [onPageStarted] which is not guaranteed to run before other scripts.
///
/// [addDocumentStartJavaScript]: https://developer.android.com/reference/androidx/webkit/WebViewCompat#addDocumentStartJavaScript(android.webkit.WebView,java.lang.String,java.util.Set%3Cjava.lang.String%3E)
/// [onPageStarted]: https://developer.android.com/reference/android/webkit/WebViewClient#onPageStarted(android.webkit.WebView,%20java.lang.String,%20android.graphics.Bitmap)
pub fn with_initialization_script(mut self, js: &str) -> Self {
if !js.is_empty() {
self.attrs.initialization_scripts.push(js.to_string());
Expand Down
Loading