web: Download the file on FileReference.save

This patch adds support for saving files on web using FileReference.
When writing data, a download is triggered with the default file name.
Currently, there's no dialog that lets the user select save destination.

This patch also ensures that all implementations of FileDialogResult
behave the same way: desktop, web, and tests.
The methods `write` and `refresh` have been merged into one:
`write_and_refresh`, which allows the tests and web implementations
behave the same way as desktop.
This commit is contained in:
Kamil Jarosz 2024-04-30 22:18:15 +02:00 committed by Nathan Adams
parent 79137ec95e
commit 8e6e71b2f7
6 changed files with 97 additions and 87 deletions

View File

@ -48,15 +48,14 @@ pub trait FileDialogResult: Downcast {
fn file_name(&self) -> Option<String>;
fn size(&self) -> Option<u64>;
fn file_type(&self) -> Option<String>;
fn creator(&self) -> Option<String>;
fn creator(&self) -> Option<String> {
None
}
fn contents(&self) -> &[u8];
/// Write the given data to the chosen file
/// This will not necessarily by reflected in future calls to other functions (such as [FileDialogResult::size]),
/// until [FileDialogResult::refresh] is called
fn write(&self, data: &[u8]);
/// Refresh any internal metadata, any future calls to other functions (such as [FileDialogResult::size]) will reflect
/// Write the given data to the chosen file and refresh any internal metadata.
/// Any future calls to other functions (such as [FileDialogResult::size]) will reflect
/// the state at the time of the last refresh
fn refresh(&mut self);
fn write_and_refresh(&mut self, data: &[u8]);
}
impl_downcast!(FileDialogResult);
@ -359,14 +358,9 @@ impl FileDialogResult for NullFileDialogResult {
fn file_type(&self) -> Option<String> {
None
}
fn creator(&self) -> Option<String> {
None
}
fn contents(&self) -> &[u8] {
&[]
}
fn write(&self, _data: &[u8]) {}
fn refresh(&mut self) {}
fn write_and_refresh(&mut self, _data: &[u8]) {}
}

View File

@ -2686,8 +2686,7 @@ impl<'gc> Loader<'gc> {
match dialog_result {
Ok(mut dialog_result) => {
if !dialog_result.is_cancelled() {
dialog_result.write(&data);
dialog_result.refresh();
dialog_result.write_and_refresh(&data);
target_object.init_from_dialog_result(dialog_result);
let mut activation = Avm2Activation::from_nothing(uc.reborrow());
@ -2840,8 +2839,7 @@ impl<'gc> Loader<'gc> {
// perspective of AS, we want to refresh the file_ref internal data
// before invoking the callbacks
dialog_result.write(&body);
dialog_result.refresh();
dialog_result.write_and_refresh(&body);
file_ref.init_from_dialog_result(
&mut activation,
dialog_result.borrow(),

View File

@ -84,21 +84,17 @@ impl FileDialogResult for DesktopFileDialogResult {
}
}
fn creator(&self) -> Option<String> {
None
}
fn contents(&self) -> &[u8] {
&self.contents
}
fn write(&self, data: &[u8]) {
fn write_and_refresh(&mut self, data: &[u8]) {
// write
if let Some(handle) = &self.handle {
let _ = std::fs::write(handle.path(), data);
}
}
fn refresh(&mut self) {
// refresh
let md = self
.handle
.as_ref()

View File

@ -1,6 +1,5 @@
use crate::test::Font;
use chrono::{DateTime, Utc};
use image::EncodableLayout;
use ruffle_core::backend::ui::{
DialogLoaderError, DialogResultFuture, FileDialogResult, FileFilter, FontDefinition,
FullscreenError, LanguageIdentifier, MouseCursor, UiBackend, US_ENGLISH,
@ -14,6 +13,7 @@ use url::Url;
pub struct TestFileDialogResult {
canceled: bool,
file_name: Option<String>,
contents: Vec<u8>,
}
impl TestFileDialogResult {
@ -21,6 +21,7 @@ impl TestFileDialogResult {
Self {
canceled: true,
file_name: None,
contents: Vec::new(),
}
}
@ -28,12 +29,11 @@ impl TestFileDialogResult {
Self {
canceled: false,
file_name: Some(file_name),
contents: b"Hello, World!".to_vec(),
}
}
}
const FILE_CONTENTS: &[u8; 13] = b"Hello, World!";
impl FileDialogResult for TestFileDialogResult {
fn is_cancelled(&self) -> bool {
self.canceled
@ -48,28 +48,24 @@ impl FileDialogResult for TestFileDialogResult {
}
fn file_name(&self) -> Option<String> {
(!self.is_cancelled()).then(|| self.file_name.clone().unwrap())
self.file_name.clone()
}
fn size(&self) -> Option<u64> {
Some(FILE_CONTENTS.len() as u64)
Some(self.contents.len() as u64)
}
fn file_type(&self) -> Option<String> {
(!self.is_cancelled()).then(|| ".txt".to_string())
}
fn creator(&self) -> Option<String> {
None
}
fn contents(&self) -> &[u8] {
FILE_CONTENTS.as_bytes()
&self.contents
}
fn write(&self, _data: &[u8]) {}
fn refresh(&mut self) {}
fn write_and_refresh(&mut self, data: &[u8]) {
self.contents = data.to_vec();
}
}
/// This is an implementation of [`UiBackend`], designed for use in tests

View File

@ -72,7 +72,8 @@ features = [
"ChannelMergerNode", "ChannelSplitterNode", "ClipboardEvent", "DataTransfer", "Element", "Event",
"EventTarget", "GainNode", "Headers", "HtmlCanvasElement", "HtmlDocument", "HtmlElement", "HtmlFormElement",
"HtmlInputElement", "HtmlTextAreaElement", "KeyboardEvent", "Location", "PointerEvent",
"Request", "RequestInit", "Response", "Storage", "WheelEvent", "Window", "ReadableStream", "RequestCredentials"
"Request", "RequestInit", "Response", "Storage", "WheelEvent", "Window", "ReadableStream", "RequestCredentials",
"Url",
]
[package.metadata.cargo-machete]

View File

@ -9,10 +9,13 @@ use ruffle_core::backend::ui::{
use ruffle_web_common::JsResult;
use std::borrow::Cow;
use url::Url;
use wasm_bindgen::JsCast;
use web_sys::{HtmlCanvasElement, HtmlDocument, HtmlTextAreaElement};
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{
Blob, HtmlCanvasElement, HtmlDocument, HtmlElement, HtmlTextAreaElement, Url as JsUrl,
};
use chrono::{DateTime, Utc};
use js_sys::{Array, Uint8Array};
#[allow(dead_code)]
#[derive(Debug)]
@ -33,19 +36,51 @@ impl std::error::Error for FullScreenError {
}
pub struct WebFileDialogResult {
handle: Option<FileHandle>,
canceled: bool,
file_name: Option<String>,
modification_time: Option<DateTime<Utc>>,
contents: Vec<u8>,
}
impl WebFileDialogResult {
pub async fn new(handle: Option<FileHandle>) -> Self {
pub async fn new_pick(handle: Option<FileHandle>) -> Self {
let contents = if let Some(handle) = handle.as_ref() {
handle.read().await
} else {
Vec::new()
};
Self { handle, contents }
#[cfg(not(target_arch = "wasm32"))]
let file_name = None;
#[cfg(target_arch = "wasm32")]
let file_name = handle.as_ref().map(|handle| handle.file_name());
#[cfg(not(target_arch = "wasm32"))]
let modification_time = None;
#[cfg(target_arch = "wasm32")]
let modification_time = if let Some(ref handle) = handle {
DateTime::from_timestamp(handle.inner().last_modified() as i64, 0)
} else {
None
};
Self {
canceled: handle.is_none(),
file_name,
modification_time,
contents,
}
}
fn new_download(file_name: String) -> Self {
Self {
canceled: false,
file_name: Some(file_name),
modification_time: None,
contents: Vec::new(),
}
}
}
@ -56,9 +91,26 @@ fn get_extension_from_filename(filename: &str) -> Option<String> {
.map(|x| ".".to_owned() + x)
}
fn download_as_file(filename: Option<&str>, data: &[u8]) -> Result<(), JsValue> {
let array = Uint8Array::from(data);
let blob = Blob::new_with_u8_array_sequence(&Array::of1(&array))?;
let window = web_sys::window().ok_or(JsValue::from("no window"))?;
let document = window.document().ok_or(JsValue::from("no document"))?;
let a = document.create_element("a")?;
let url = JsUrl::create_object_url_with_blob(&blob)?;
a.set_attribute("href", &url)?;
a.set_attribute("download", filename.unwrap_or(""))?;
a.dyn_into::<HtmlElement>()
.map_err(|_| JsValue::from("not an HtmlElement"))?
.click();
JsUrl::revoke_object_url(&url)?;
Ok(())
}
impl FileDialogResult for WebFileDialogResult {
fn is_cancelled(&self) -> bool {
self.handle.is_none()
self.canceled
}
fn creation_time(&self) -> Option<DateTime<Utc>> {
@ -67,53 +119,37 @@ impl FileDialogResult for WebFileDialogResult {
}
fn modification_time(&self) -> Option<DateTime<Utc>> {
#[cfg(target_arch = "wasm32")]
if let Some(handle) = &self.handle {
DateTime::from_timestamp(handle.inner().last_modified() as i64, 0)
} else {
None
}
#[cfg(not(target_arch = "wasm32"))]
None
self.modification_time
}
fn file_name(&self) -> Option<String> {
#[cfg(target_arch = "wasm32")]
return self.handle.as_ref().map(|handle| handle.file_name());
#[cfg(not(target_arch = "wasm32"))]
None
self.file_name.clone()
}
fn size(&self) -> Option<u64> {
#[cfg(target_arch = "wasm32")]
return self.handle.as_ref().map(|x| x.inner().size() as u64);
#[cfg(not(target_arch = "wasm32"))]
None
Some(self.contents.len() as u64)
}
fn file_type(&self) -> Option<String> {
if let Some(handle) = &self.handle {
get_extension_from_filename(&handle.file_name())
if let Some(ref file_name) = self.file_name {
get_extension_from_filename(file_name)
} else {
None
}
}
fn creator(&self) -> Option<String> {
None
}
fn contents(&self) -> &[u8] {
&self.contents
}
fn write(&self, _data: &[u8]) {
//NOOP
}
fn write_and_refresh(&mut self, data: &[u8]) {
self.contents = data.to_vec();
self.modification_time = Some(Utc::now());
fn refresh(&mut self) {}
if let Err(err) = download_as_file(self.file_name.as_deref(), &self.contents[..]) {
tracing::error!("Download failed: {:?}", err);
}
}
}
/// An implementation of `UiBackend` utilizing `web_sys` bindings to input APIs.
@ -300,7 +336,7 @@ impl UiBackend for WebUiBackend {
}
let result: Result<Box<dyn FileDialogResult>, DialogLoaderError> = Ok(Box::new(
WebFileDialogResult::new(dialog.pick_file().await).await,
WebFileDialogResult::new_pick(dialog.pick_file().await).await,
));
result
}))
@ -312,30 +348,19 @@ impl UiBackend for WebUiBackend {
fn display_file_save_dialog(
&mut self,
_file_name: String,
file_name: String,
_title: String,
) -> Option<DialogResultFuture> {
None
/* Temporary disabled while #14949 is being fixed
// Prevent opening multiple dialogs at the same time
if self.dialog_open {
return None;
}
self.dialog_open = true;
// Create the dialog future
Some(Box::pin(async move {
// Select the location to save the file to
let dialog = AsyncFileDialog::new()
.set_title(&title)
.set_file_name(&file_name);
let result: Result<Box<dyn FileDialogResult>, DialogLoaderError> = Ok(Box::new(
WebFileDialogResult::new(dialog.save_file().await).await,
));
let result: Result<Box<dyn FileDialogResult>, DialogLoaderError> =
Ok(Box::new(WebFileDialogResult::new_download(file_name)));
result
}))
*/
}
}