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

View File

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

View File

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

View File

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

View File

@ -72,7 +72,8 @@ features = [
"ChannelMergerNode", "ChannelSplitterNode", "ClipboardEvent", "DataTransfer", "Element", "Event", "ChannelMergerNode", "ChannelSplitterNode", "ClipboardEvent", "DataTransfer", "Element", "Event",
"EventTarget", "GainNode", "Headers", "HtmlCanvasElement", "HtmlDocument", "HtmlElement", "HtmlFormElement", "EventTarget", "GainNode", "Headers", "HtmlCanvasElement", "HtmlDocument", "HtmlElement", "HtmlFormElement",
"HtmlInputElement", "HtmlTextAreaElement", "KeyboardEvent", "Location", "PointerEvent", "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] [package.metadata.cargo-machete]

View File

@ -9,10 +9,13 @@ use ruffle_core::backend::ui::{
use ruffle_web_common::JsResult; use ruffle_web_common::JsResult;
use std::borrow::Cow; use std::borrow::Cow;
use url::Url; use url::Url;
use wasm_bindgen::JsCast; use wasm_bindgen::{JsCast, JsValue};
use web_sys::{HtmlCanvasElement, HtmlDocument, HtmlTextAreaElement}; use web_sys::{
Blob, HtmlCanvasElement, HtmlDocument, HtmlElement, HtmlTextAreaElement, Url as JsUrl,
};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use js_sys::{Array, Uint8Array};
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug)] #[derive(Debug)]
@ -33,19 +36,51 @@ impl std::error::Error for FullScreenError {
} }
pub struct WebFileDialogResult { pub struct WebFileDialogResult {
handle: Option<FileHandle>, canceled: bool,
file_name: Option<String>,
modification_time: Option<DateTime<Utc>>,
contents: Vec<u8>, contents: Vec<u8>,
} }
impl WebFileDialogResult { 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() { let contents = if let Some(handle) = handle.as_ref() {
handle.read().await handle.read().await
} else { } else {
Vec::new() 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) .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 { impl FileDialogResult for WebFileDialogResult {
fn is_cancelled(&self) -> bool { fn is_cancelled(&self) -> bool {
self.handle.is_none() self.canceled
} }
fn creation_time(&self) -> Option<DateTime<Utc>> { fn creation_time(&self) -> Option<DateTime<Utc>> {
@ -67,53 +119,37 @@ impl FileDialogResult for WebFileDialogResult {
} }
fn modification_time(&self) -> Option<DateTime<Utc>> { fn modification_time(&self) -> Option<DateTime<Utc>> {
#[cfg(target_arch = "wasm32")] self.modification_time
if let Some(handle) = &self.handle {
DateTime::from_timestamp(handle.inner().last_modified() as i64, 0)
} else {
None
}
#[cfg(not(target_arch = "wasm32"))]
None
} }
fn file_name(&self) -> Option<String> { fn file_name(&self) -> Option<String> {
#[cfg(target_arch = "wasm32")] self.file_name.clone()
return self.handle.as_ref().map(|handle| handle.file_name());
#[cfg(not(target_arch = "wasm32"))]
None
} }
fn size(&self) -> Option<u64> { fn size(&self) -> Option<u64> {
#[cfg(target_arch = "wasm32")] Some(self.contents.len() as u64)
return self.handle.as_ref().map(|x| x.inner().size() as u64);
#[cfg(not(target_arch = "wasm32"))]
None
} }
fn file_type(&self) -> Option<String> { fn file_type(&self) -> Option<String> {
if let Some(handle) = &self.handle { if let Some(ref file_name) = self.file_name {
get_extension_from_filename(&handle.file_name()) get_extension_from_filename(file_name)
} else { } else {
None None
} }
} }
fn creator(&self) -> Option<String> {
None
}
fn contents(&self) -> &[u8] { fn contents(&self) -> &[u8] {
&self.contents &self.contents
} }
fn write(&self, _data: &[u8]) { fn write_and_refresh(&mut self, data: &[u8]) {
//NOOP 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. /// 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( 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 result
})) }))
@ -312,30 +348,19 @@ impl UiBackend for WebUiBackend {
fn display_file_save_dialog( fn display_file_save_dialog(
&mut self, &mut self,
_file_name: String, file_name: String,
_title: String, _title: String,
) -> Option<DialogResultFuture> { ) -> Option<DialogResultFuture> {
None
/* Temporary disabled while #14949 is being fixed
// Prevent opening multiple dialogs at the same time // Prevent opening multiple dialogs at the same time
if self.dialog_open { if self.dialog_open {
return None; return None;
} }
self.dialog_open = true; self.dialog_open = true;
// Create the dialog future
Some(Box::pin(async move { Some(Box::pin(async move {
// Select the location to save the file to let result: Result<Box<dyn FileDialogResult>, DialogLoaderError> =
let dialog = AsyncFileDialog::new() Ok(Box::new(WebFileDialogResult::new_download(file_name)));
.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,
));
result result
})) }))
*/
} }
} }