diff --git a/core/src/backend/ui.rs b/core/src/backend/ui.rs index cd088b3a2..203268da4 100644 --- a/core/src/backend/ui.rs +++ b/core/src/backend/ui.rs @@ -48,15 +48,14 @@ pub trait FileDialogResult: Downcast { fn file_name(&self) -> Option; fn size(&self) -> Option; fn file_type(&self) -> Option; - fn creator(&self) -> Option; + fn creator(&self) -> Option { + 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 { None } - fn creator(&self) -> Option { - None - } - fn contents(&self) -> &[u8] { &[] } - fn write(&self, _data: &[u8]) {} - fn refresh(&mut self) {} + fn write_and_refresh(&mut self, _data: &[u8]) {} } diff --git a/core/src/loader.rs b/core/src/loader.rs index b94020587..4a9bfdb5f 100644 --- a/core/src/loader.rs +++ b/core/src/loader.rs @@ -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(), diff --git a/desktop/src/backends/ui.rs b/desktop/src/backends/ui.rs index 207714da7..6f17cf96f 100644 --- a/desktop/src/backends/ui.rs +++ b/desktop/src/backends/ui.rs @@ -84,21 +84,17 @@ impl FileDialogResult for DesktopFileDialogResult { } } - fn creator(&self) -> Option { - 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() diff --git a/tests/framework/src/backends/ui.rs b/tests/framework/src/backends/ui.rs index 4ca5816f7..45ff8b9fb 100644 --- a/tests/framework/src/backends/ui.rs +++ b/tests/framework/src/backends/ui.rs @@ -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, + contents: Vec, } 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 { - (!self.is_cancelled()).then(|| self.file_name.clone().unwrap()) + self.file_name.clone() } fn size(&self) -> Option { - Some(FILE_CONTENTS.len() as u64) + Some(self.contents.len() as u64) } fn file_type(&self) -> Option { (!self.is_cancelled()).then(|| ".txt".to_string()) } - fn creator(&self) -> Option { - 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 diff --git a/web/Cargo.toml b/web/Cargo.toml index bdbb20a27..c8de1fb97 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -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] diff --git a/web/src/ui.rs b/web/src/ui.rs index 186ae3125..4e2497379 100644 --- a/web/src/ui.rs +++ b/web/src/ui.rs @@ -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, + canceled: bool, + file_name: Option, + modification_time: Option>, contents: Vec, } impl WebFileDialogResult { - pub async fn new(handle: Option) -> Self { + pub async fn new_pick(handle: Option) -> 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 { .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::() + .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> { @@ -67,53 +119,37 @@ impl FileDialogResult for WebFileDialogResult { } fn modification_time(&self) -> Option> { - #[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 { - #[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 { - #[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 { - 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 { - 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, 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 { - 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, DialogLoaderError> = Ok(Box::new( - WebFileDialogResult::new(dialog.save_file().await).await, - )); + let result: Result, DialogLoaderError> = + Ok(Box::new(WebFileDialogResult::new_download(file_name))); result })) - */ } }