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:
parent
79137ec95e
commit
8e6e71b2f7
|
@ -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]) {}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
123
web/src/ui.rs
123
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<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
|
||||
}))
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue