avm1: Implement LoadVars

This commit is contained in:
Mike Welsh 2020-07-22 18:22:23 -07:00
parent 74cb8609c1
commit 1709e76409
4 changed files with 543 additions and 12 deletions

View File

@ -2488,23 +2488,22 @@ impl<'a, 'gc: 'a> Activation<'a, 'gc> {
} }
} }
/// Convert the current locals pool into a set of form values. /// Convert the enumerable properties of an object into a set of form values.
/// ///
/// This is necessary to support form submission from Flash via a couple of /// This is necessary to support form submission from Flash via a couple of
/// legacy methods, such as the `ActionGetURL2` opcode or `getURL` function. /// legacy methods, such as the `ActionGetURL2` opcode or `getURL` function.
/// ///
/// WARNING: This does not support user defined virtual properties! /// WARNING: This does not support user defined virtual properties!
pub fn locals_into_form_values( pub fn object_into_form_values(
&mut self, &mut self,
context: &mut UpdateContext<'_, 'gc, '_>, context: &mut UpdateContext<'_, 'gc, '_>,
object: Object<'gc>,
) -> HashMap<String, String> { ) -> HashMap<String, String> {
let mut form_values = HashMap::new(); let mut form_values = HashMap::new();
let scope = self.scope_cell(); let keys = object.get_keys(self);
let locals = scope.read().locals_cell();
let keys = locals.get_keys(self);
for k in keys { for k in keys {
let v = locals.get(&k, self, context); let v = object.get(&k, self, context);
//TODO: What happens if an error occurs inside a virtual property? //TODO: What happens if an error occurs inside a virtual property?
form_values.insert( form_values.insert(
@ -2520,17 +2519,18 @@ impl<'a, 'gc: 'a> Activation<'a, 'gc> {
form_values form_values
} }
/// Construct request options for a fetch operation that may send locals as /// Construct request options for a fetch operation that may sends object properties as
/// form data in the request body or URL. /// form data in the request body or URL.
pub fn locals_into_request_options<'b>( pub fn object_into_request_options<'b>(
&mut self, &mut self,
context: &mut UpdateContext<'_, 'gc, '_>, context: &mut UpdateContext<'_, 'gc, '_>,
object: Object<'gc>,
url: Cow<'b, str>, url: Cow<'b, str>,
method: Option<NavigationMethod>, method: Option<NavigationMethod>,
) -> (Cow<'b, str>, RequestOptions) { ) -> (Cow<'b, str>, RequestOptions) {
match method { match method {
Some(method) => { Some(method) => {
let vars = self.locals_into_form_values(context); let vars = self.object_into_form_values(context, object);
let qstring = form_urlencoded::Serializer::new(String::new()) let qstring = form_urlencoded::Serializer::new(String::new())
.extend_pairs(vars.iter()) .extend_pairs(vars.iter())
.finish(); .finish();
@ -2557,6 +2557,34 @@ impl<'a, 'gc: 'a> Activation<'a, 'gc> {
} }
} }
/// Convert the current locals pool into a set of form values.
///
/// This is necessary to support form submission from Flash via a couple of
/// legacy methods, such as the `ActionGetURL2` opcode or `getURL` function.
///
/// WARNING: This does not support user defined virtual properties!
pub fn locals_into_form_values(
&mut self,
context: &mut UpdateContext<'_, 'gc, '_>,
) -> HashMap<String, String> {
let scope = self.scope_cell();
let locals = scope.read().locals_cell();
self.object_into_form_values(context, locals)
}
/// Construct request options for a fetch operation that may send locals as
/// form data in the request body or URL.
pub fn locals_into_request_options<'b>(
&mut self,
context: &mut UpdateContext<'_, 'gc, '_>,
url: Cow<'b, str>,
method: Option<NavigationMethod>,
) -> (Cow<'b, str>, RequestOptions) {
let scope = self.scope_cell();
let locals = scope.read().locals_cell();
self.object_into_request_options(context, locals, url, method)
}
/// Resolves a target value to a display object, relative to a starting display object. /// Resolves a target value to a display object, relative to a starting display object.
/// ///
/// This is used by any action/function with a parameter that can be either /// This is used by any action/function with a parameter that can be either

View File

@ -20,6 +20,7 @@ pub(crate) mod display_object;
pub(crate) mod error; pub(crate) mod error;
mod function; mod function;
mod key; mod key;
mod load_vars;
mod math; mod math;
mod matrix; mod matrix;
pub(crate) mod mouse; pub(crate) mod mouse;
@ -250,6 +251,8 @@ pub fn create_globals<'gc>(
let number_proto: Object<'gc> = number::create_proto(gc_context, object_proto, function_proto); let number_proto: Object<'gc> = number::create_proto(gc_context, object_proto, function_proto);
let boolean_proto: Object<'gc> = let boolean_proto: Object<'gc> =
boolean::create_proto(gc_context, object_proto, function_proto); boolean::create_proto(gc_context, object_proto, function_proto);
let load_vars_proto: Object<'gc> =
load_vars::create_proto(gc_context, object_proto, function_proto);
let matrix_proto: Object<'gc> = matrix::create_proto(gc_context, object_proto, function_proto); let matrix_proto: Object<'gc> = matrix::create_proto(gc_context, object_proto, function_proto);
let point_proto: Object<'gc> = point::create_proto(gc_context, object_proto, function_proto); let point_proto: Object<'gc> = point::create_proto(gc_context, object_proto, function_proto);
let rectangle_proto: Object<'gc> = let rectangle_proto: Object<'gc> =
@ -288,6 +291,12 @@ pub fn create_globals<'gc>(
Some(function_proto), Some(function_proto),
Some(function_proto), Some(function_proto),
); );
let load_vars = FunctionObject::function(
gc_context,
Executable::Native(load_vars::constructor),
Some(function_proto),
Some(load_vars_proto),
);
let movie_clip = FunctionObject::function( let movie_clip = FunctionObject::function(
gc_context, gc_context,
Executable::Native(movie_clip::constructor), Executable::Native(movie_clip::constructor),
@ -371,6 +380,7 @@ pub fn create_globals<'gc>(
globals.define_value(gc_context, "Error", error.into(), EnumSet::empty()); globals.define_value(gc_context, "Error", error.into(), EnumSet::empty());
globals.define_value(gc_context, "Object", object.into(), EnumSet::empty()); globals.define_value(gc_context, "Object", object.into(), EnumSet::empty());
globals.define_value(gc_context, "Function", function.into(), EnumSet::empty()); globals.define_value(gc_context, "Function", function.into(), EnumSet::empty());
globals.define_value(gc_context, "LoadVars", load_vars.into(), EnumSet::empty());
globals.define_value(gc_context, "MovieClip", movie_clip.into(), EnumSet::empty()); globals.define_value(gc_context, "MovieClip", movie_clip.into(), EnumSet::empty());
globals.define_value( globals.define_value(
gc_context, gc_context,

View File

@ -0,0 +1,383 @@
//! AVM1 LoadVars object
//! TODO: bytesLoaded, bytesTotal, contentType, addRequestHeader
use crate::avm1::activation::Activation;
use crate::avm1::error::Error;
use crate::avm1::property::Attribute;
use crate::avm1::{AvmString, Object, ScriptObject, TObject, UpdateContext, Value};
use crate::backend::navigator::{NavigationMethod, RequestOptions};
use gc_arena::MutationContext;
use std::borrow::Cow;
/// Implements `LoadVars`
pub fn constructor<'gc>(
_activation: &mut Activation<'_, 'gc>,
_context: &mut UpdateContext<'_, 'gc, '_>,
_this: Object<'gc>,
_args: &[Value<'gc>],
) -> Result<Value<'gc>, Error<'gc>> {
// No-op constructor
Ok(Value::Undefined)
}
pub fn create_proto<'gc>(
gc_context: MutationContext<'gc, '_>,
proto: Object<'gc>,
fn_proto: Object<'gc>,
) -> Object<'gc> {
use Attribute::*;
let mut object = ScriptObject::object(gc_context, Some(proto));
object.force_set_function(
"load",
load,
gc_context,
DontDelete | DontEnum | ReadOnly,
Some(fn_proto),
);
object.force_set_function(
"send",
send,
gc_context,
DontDelete | DontEnum | ReadOnly,
Some(fn_proto),
);
object.force_set_function(
"sendAndLoad",
send_and_load,
gc_context,
DontDelete | DontEnum | ReadOnly,
Some(fn_proto),
);
object.force_set_function(
"decode",
decode,
gc_context,
DontDelete | DontEnum | ReadOnly,
Some(fn_proto),
);
object.force_set_function(
"getBytesLoaded",
get_bytes_loaded,
gc_context,
DontDelete | DontEnum | ReadOnly,
Some(fn_proto),
);
object.force_set_function(
"getBytesTotal",
get_bytes_total,
gc_context,
DontDelete | DontEnum | ReadOnly,
Some(fn_proto),
);
object.force_set_function(
"toString",
to_string,
gc_context,
DontDelete | DontEnum | ReadOnly,
Some(fn_proto),
);
object.define_value(
gc_context,
"contentType",
"application/x-www-form-url-encoded".into(),
DontDelete | DontEnum | ReadOnly,
);
object.force_set_function(
"onLoad",
on_load,
gc_context,
DontDelete | DontEnum | ReadOnly,
Some(fn_proto),
);
object.force_set_function(
"onData",
on_data,
gc_context,
DontDelete | DontEnum | ReadOnly,
Some(fn_proto),
);
object.force_set_function(
"addRequestHeader",
add_request_header,
gc_context,
DontDelete | DontEnum | ReadOnly,
Some(fn_proto),
);
object.into()
}
fn add_request_header<'gc>(
_activation: &mut Activation<'_, 'gc>,
_context: &mut UpdateContext<'_, 'gc, '_>,
_this: Object<'gc>,
_args: &[Value<'gc>],
) -> Result<Value<'gc>, Error<'gc>> {
log::warn!("LoadVars.addRequestHeader: Unimplemented");
Ok(Value::Undefined)
}
fn decode<'gc>(
activation: &mut Activation<'_, 'gc>,
context: &mut UpdateContext<'_, 'gc, '_>,
this: Object<'gc>,
args: &[Value<'gc>],
) -> Result<Value<'gc>, Error<'gc>> {
// Spec says added in SWF 7, but not version gated.
// Decode the query string into properties on this object.
if let Some(data) = args.get(0) {
let data = data.coerce_to_string(activation, context)?;
for (k, v) in url::form_urlencoded::parse(data.as_bytes()) {
this.set(
&k,
crate::avm1::AvmString::new(context.gc_context, v.into_owned()).into(),
activation,
context,
)?;
}
}
Ok(Value::Undefined)
}
fn get_bytes_loaded<'gc>(
activation: &mut Activation<'_, 'gc>,
context: &mut UpdateContext<'_, 'gc, '_>,
this: Object<'gc>,
_args: &[Value<'gc>],
) -> Result<Value<'gc>, Error<'gc>> {
// Forwards to undocumented property on the object.
this.get("_bytesLoaded", activation, context)
}
fn get_bytes_total<'gc>(
activation: &mut Activation<'_, 'gc>,
context: &mut UpdateContext<'_, 'gc, '_>,
this: Object<'gc>,
_args: &[Value<'gc>],
) -> Result<Value<'gc>, Error<'gc>> {
// Forwards to undocumented property on the object.
this.get("_bytesTotal", activation, context)
}
fn load<'gc>(
activation: &mut Activation<'_, 'gc>,
context: &mut UpdateContext<'_, 'gc, '_>,
this: Object<'gc>,
args: &[Value<'gc>],
) -> Result<Value<'gc>, Error<'gc>> {
let url = match args.get(0) {
Some(val) => val.coerce_to_string(activation, context)?,
None => return Ok(false.into()),
};
spawn_load_var_fetch(activation, context, this, &url, None)?;
Ok(true.into())
}
fn on_data<'gc>(
activation: &mut Activation<'_, 'gc>,
context: &mut UpdateContext<'_, 'gc, '_>,
this: Object<'gc>,
args: &[Value<'gc>],
) -> Result<Value<'gc>, Error<'gc>> {
// Default implementation forwards to decode and onLoad.
let success = match args.get(0) {
None | Some(Value::Undefined) | Some(Value::Null) => false,
Some(val) => {
this.call_method(&"decode", &[val.clone()], activation, context)?;
this.set("loaded", true.into(), activation, context)?;
true
}
};
this.call_method(&"onLoad", &[success.into()], activation, context)?;
Ok(Value::Undefined)
}
fn on_load<'gc>(
_activation: &mut Activation<'_, 'gc>,
_context: &mut UpdateContext<'_, 'gc, '_>,
_this: Object<'gc>,
_args: &[Value<'gc>],
) -> Result<Value<'gc>, Error<'gc>> {
// Default implementation: no-op?
Ok(Value::Undefined)
}
fn send<'gc>(
activation: &mut Activation<'_, 'gc>,
context: &mut UpdateContext<'_, 'gc, '_>,
this: Object<'gc>,
args: &[Value<'gc>],
) -> Result<Value<'gc>, Error<'gc>> {
// `send` navigates the browser to a URL with the given query parameter.
let url = match args.get(0) {
Some(url) => url.coerce_to_string(activation, context)?,
None => return Ok(false.into()),
};
let window = match args.get(1) {
Some(v) => Some(v.coerce_to_string(activation, context)?),
None => None,
};
let method_name = args
.get(1)
.unwrap_or(&Value::Undefined)
.coerce_to_string(activation, context)?;
let method = NavigationMethod::from_method_str(&method_name).unwrap_or(NavigationMethod::POST);
use std::collections::HashMap;
let mut form_values = HashMap::new();
let keys = this.get_keys(activation);
for k in keys {
let v = this.get(&k, activation, context);
form_values.insert(
k,
v.ok()
.unwrap_or_else(|| Value::Undefined)
.coerce_to_string(activation, context)
.unwrap_or_else(|_| "undefined".into())
.to_string(),
);
}
if let Some(window) = window {
context.navigator.navigate_to_url(
url.to_string(),
Some(window.to_string()),
Some((method, form_values)),
);
}
Ok(true.into())
}
fn send_and_load<'gc>(
activation: &mut Activation<'_, 'gc>,
context: &mut UpdateContext<'_, 'gc, '_>,
this: Object<'gc>,
args: &[Value<'gc>],
) -> Result<Value<'gc>, Error<'gc>> {
let url_val = args.get(0).cloned().unwrap_or(Value::Undefined);
let url = url_val.coerce_to_string(activation, context)?;
let target = match args.get(1) {
Some(&Value::Object(o)) => o,
_ => return Ok(false.into()),
};
let method_name = args
.get(2)
.unwrap_or(&Value::Undefined)
.coerce_to_string(activation, context)?;
let method = NavigationMethod::from_method_str(&method_name).unwrap_or(NavigationMethod::POST);
spawn_load_var_fetch(activation, context, target, &url, Some((this, method)))?;
Ok(true.into())
}
fn to_string<'gc>(
activation: &mut Activation<'_, 'gc>,
context: &mut UpdateContext<'_, 'gc, '_>,
this: Object<'gc>,
_args: &[Value<'gc>],
) -> Result<Value<'gc>, Error<'gc>> {
use std::collections::HashMap;
let mut form_values = HashMap::new();
let keys = this.get_keys(activation);
for k in keys {
let v = this.get(&k, activation, context);
//TODO: What happens if an error occurs inside a virtual property?
form_values.insert(
k,
v.ok()
.unwrap_or_else(|| Value::Undefined)
.coerce_to_string(activation, context)
.unwrap_or_else(|_| "undefined".into())
.to_string(),
);
}
let query_string = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(form_values.iter())
.finish();
Ok(crate::avm1::AvmString::new(context.gc_context, query_string).into())
}
fn spawn_load_var_fetch<'gc>(
activation: &mut Activation<'_, 'gc>,
context: &mut UpdateContext<'_, 'gc, '_>,
loader_object: Object<'gc>,
url: &AvmString,
send_object: Option<(Object<'gc>, NavigationMethod)>,
) -> Result<Value<'gc>, Error<'gc>> {
let (url, request_options) = if let Some((send_object, method)) = send_object {
// Send properties from `send_object`.
activation.object_into_request_options(
context,
send_object,
Cow::Borrowed(&url),
Some(method),
)
} else {
// Not sending any parameters.
(Cow::Borrowed(url.as_str()), RequestOptions::get())
};
let fetch = context.navigator.fetch(&url, request_options);
let process = context.load_manager.load_form_into_load_vars(
context.player.clone().unwrap(),
loader_object,
fetch,
);
// Create hidden properties on object.
if !loader_object.has_property(activation, context, "_bytesLoaded") {
loader_object.define_value(
context.gc_context,
"_bytesLoaded",
0.into(),
Attribute::DontDelete | Attribute::DontEnum,
);
} else {
loader_object.set("_bytesLoaded", 0.into(), activation, context)?;
}
if !loader_object.has_property(activation, context, "loaded") {
loader_object.define_value(
context.gc_context,
"loaded",
false.into(),
Attribute::DontDelete | Attribute::DontEnum,
);
} else {
loader_object.set("loaded", false.into(), activation, context)?;
}
context.navigator.spawn_future(process);
Ok(true.into())
}

View File

@ -3,7 +3,7 @@
use crate::avm1::activation::{Activation, ActivationIdentifier}; use crate::avm1::activation::{Activation, ActivationIdentifier};
use crate::avm1::{AvmString, Object, TObject, Value}; use crate::avm1::{AvmString, Object, TObject, Value};
use crate::backend::navigator::OwnedFuture; use crate::backend::navigator::OwnedFuture;
use crate::context::{ActionQueue, ActionType}; use crate::context::{ActionQueue, ActionType, UpdateContext};
use crate::display_object::{DisplayObject, MorphShape, TDisplayObject}; use crate::display_object::{DisplayObject, MorphShape, TDisplayObject};
use crate::player::{Player, NEWEST_PLAYER_VERSION}; use crate::player::{Player, NEWEST_PLAYER_VERSION};
use crate::tag_utils::SwfMovie; use crate::tag_utils::SwfMovie;
@ -28,6 +28,9 @@ pub enum Error {
#[error("Non-form loader spawned as form loader")] #[error("Non-form loader spawned as form loader")]
NotFormLoader, NotFormLoader,
#[error("Non-load vars loader spawned as load vars loader")]
NotLoadVarsLoader,
#[error("Non-XML loader spawned as XML loader")] #[error("Non-XML loader spawned as XML loader")]
NotXmlLoader, NotXmlLoader,
@ -46,6 +49,16 @@ pub enum Error {
Avm1Error(String), Avm1Error(String),
} }
pub type FormLoadHandler<'gc> = fn(
&mut Activation<'_, 'gc>,
&mut UpdateContext<'_, 'gc, '_>,
Object<'gc>,
data: &[u8],
) -> Result<(), Error>;
pub type FormErrorHandler<'gc> =
fn(&mut Activation<'_, 'gc>, &mut UpdateContext<'_, 'gc, '_>, Object<'gc>) -> Result<(), Error>;
impl From<crate::avm1::error::Error<'_>> for Error { impl From<crate::avm1::error::Error<'_>> for Error {
fn from(error: crate::avm1::error::Error<'_>) -> Self { fn from(error: crate::avm1::error::Error<'_>) -> Self {
Error::Avm1Error(error.to_string()) Error::Avm1Error(error.to_string())
@ -163,6 +176,27 @@ impl<'gc> LoadManager<'gc> {
loader.form_loader(player, fetch) loader.form_loader(player, fetch)
} }
/// Kick off a form data load into an AVM1 object.
///
/// Returns the loader's async process, which you will need to spawn.
pub fn load_form_into_load_vars(
&mut self,
player: Weak<Mutex<Player>>,
target_object: Object<'gc>,
fetch: OwnedFuture<Vec<u8>, Error>,
) -> OwnedFuture<(), Error> {
let loader = Loader::LoadVars {
self_handle: None,
target_object,
};
let handle = self.add_loader(loader);
let loader = self.get_loader_mut(handle).unwrap();
loader.introduce_loader_handle(handle);
loader.load_vars_loader(player, fetch)
}
/// Kick off an XML data load into an XML node. /// Kick off an XML data load into an XML node.
/// ///
/// Returns the loader's async process, which you will need to spawn. /// Returns the loader's async process, which you will need to spawn.
@ -227,6 +261,15 @@ pub enum Loader<'gc> {
target_object: Object<'gc>, target_object: Object<'gc>,
}, },
/// Loader that is loading form data into an AVM1 LoadVars object.
LoadVars {
/// The handle to refer to this loader instance.
self_handle: Option<Handle>,
/// The target AVM1 object to load form data into.
target_object: Object<'gc>,
},
/// Loader that is loading XML data into an XML tree. /// Loader that is loading XML data into an XML tree.
XML { XML {
/// The handle to refer to this loader instance. /// The handle to refer to this loader instance.
@ -257,6 +300,7 @@ unsafe impl<'gc> Collect for Loader<'gc> {
target_broadcaster.trace(cc); target_broadcaster.trace(cc);
} }
Loader::Form { target_object, .. } => target_object.trace(cc), Loader::Form { target_object, .. } => target_object.trace(cc),
Loader::LoadVars { target_object, .. } => target_object.trace(cc),
Loader::XML { target_node, .. } => target_node.trace(cc), Loader::XML { target_node, .. } => target_node.trace(cc),
} }
} }
@ -271,6 +315,7 @@ impl<'gc> Loader<'gc> {
match self { match self {
Loader::Movie { self_handle, .. } => *self_handle = Some(handle), Loader::Movie { self_handle, .. } => *self_handle = Some(handle),
Loader::Form { self_handle, .. } => *self_handle = Some(handle), Loader::Form { self_handle, .. } => *self_handle = Some(handle),
Loader::LoadVars { self_handle, .. } => *self_handle = Some(handle),
Loader::XML { self_handle, .. } => *self_handle = Some(handle), Loader::XML { self_handle, .. } => *self_handle = Some(handle),
} }
} }
@ -467,12 +512,13 @@ impl<'gc> Loader<'gc> {
Box::pin(async move { Box::pin(async move {
let data = fetch.await?; let data = fetch.await?;
// Fire the load handler.
player.lock().unwrap().update(|avm1, _avm2, uc| { player.lock().unwrap().update(|avm1, _avm2, uc| {
let loader = uc.load_manager.get_loader(handle); let loader = uc.load_manager.get_loader(handle);
let that = match loader { let that = match loader {
Some(Loader::Form { target_object, .. }) => *target_object, Some(&Loader::Form { target_object, .. }) => target_object,
None => return Err(Error::Cancelled), None => return Err(Error::Cancelled),
_ => return Err(Error::NotMovieLoader), _ => return Err(Error::NotFormLoader),
}; };
let mut activation = Activation::from_nothing( let mut activation = Activation::from_nothing(
@ -498,6 +544,70 @@ impl<'gc> Loader<'gc> {
}) })
} }
/// Creates a future for a LoadVars load call.
pub fn load_vars_loader(
&mut self,
player: Weak<Mutex<Player>>,
fetch: OwnedFuture<Vec<u8>, Error>,
) -> OwnedFuture<(), Error> {
let handle = match self {
Loader::LoadVars { self_handle, .. } => {
self_handle.expect("Loader not self-introduced")
}
_ => return Box::pin(async { Err(Error::NotLoadVarsLoader) }),
};
let player = player
.upgrade()
.expect("Could not upgrade weak reference to player");
Box::pin(async move {
let data = fetch.await;
// Fire the load handler.
player.lock().unwrap().update(|avm1, _avm2, uc| {
let loader = uc.load_manager.get_loader(handle);
let that = match loader {
Some(&Loader::LoadVars { target_object, .. }) => target_object,
None => return Err(Error::Cancelled),
_ => return Err(Error::NotLoadVarsLoader),
};
let mut activation = Activation::from_nothing(
avm1,
ActivationIdentifier::root("[Form Loader]"),
uc.swf.version(),
avm1.global_object_cell(),
uc.gc_context,
*uc.levels.get(&0).unwrap(),
);
match data {
Ok(data) => {
// Fire the onData method with the loaded string.
let string_data =
AvmString::new(uc.gc_context, String::from_utf8_lossy(&data));
let _ =
that.call_method("onData", &[string_data.into()], &mut activation, uc);
}
Err(_) => {
// TODO: Log "Error opening URL" trace similar to the Flash Player?
// Simulate 404 HTTP status. This should probably be fired elsewhere
// because a failed local load doesn't fire a 404.
let _ =
that.call_method("onHTTPStatus", &[404.into()], &mut activation, uc);
// Fire the onData method with no data to indicate an unsuccessful load.
let _ =
that.call_method("onData", &[Value::Undefined], &mut activation, uc);
}
}
Ok(())
})
})
}
/// Event handler morally equivalent to `onLoad` on a movie clip. /// Event handler morally equivalent to `onLoad` on a movie clip.
/// ///
/// Returns `true` if the loader has completed and should be removed. /// Returns `true` if the loader has completed and should be removed.