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 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) {}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
|
|
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 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
|
||||||
}))
|
}))
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue