desktop: Allow opening folder based bundles

This commit is contained in:
Nathan Adams 2024-04-05 21:46:11 +02:00
parent 961bc0a7c5
commit 4632a14376
4 changed files with 169 additions and 65 deletions

View File

@ -1,6 +1,7 @@
//! Navigator backend for web //! Navigator backend for web
use crate::executor::FutureSpawner; use crate::executor::FutureSpawner;
use crate::player::PlayingContent;
use async_channel::{Receiver, Sender, TryRecvError}; use async_channel::{Receiver, Sender, TryRecvError};
use async_io::Timer; use async_io::Timer;
use async_net::TcpStream; use async_net::TcpStream;
@ -21,9 +22,9 @@ use ruffle_core::indexmap::IndexMap;
use ruffle_core::loader::Error; use ruffle_core::loader::Error;
use ruffle_core::socket::{ConnectionState, SocketAction, SocketHandle}; use ruffle_core::socket::{ConnectionState, SocketAction, SocketHandle};
use std::collections::HashSet; use std::collections::HashSet;
use std::fs::File; use std::io;
use std::io::ErrorKind; use std::io::ErrorKind;
use std::io::{self, Read}; use std::path::Path;
use std::rc::Rc; use std::rc::Rc;
use std::str::FromStr; use std::str::FromStr;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -50,6 +51,8 @@ pub struct ExternalNavigatorBackend<F: FutureSpawner> {
upgrade_to_https: bool, upgrade_to_https: bool,
open_url_mode: OpenURLMode, open_url_mode: OpenURLMode,
content: Arc<PlayingContent>,
} }
impl<F: FutureSpawner> ExternalNavigatorBackend<F> { impl<F: FutureSpawner> ExternalNavigatorBackend<F> {
@ -63,6 +66,7 @@ impl<F: FutureSpawner> ExternalNavigatorBackend<F> {
open_url_mode: OpenURLMode, open_url_mode: OpenURLMode,
socket_allowed: HashSet<String>, socket_allowed: HashSet<String>,
socket_mode: SocketMode, socket_mode: SocketMode,
content: Arc<PlayingContent>,
) -> Self { ) -> Self {
let proxy = proxy.and_then(|url| url.as_str().parse().ok()); let proxy = proxy.and_then(|url| url.as_str().parse().ok());
let builder = HttpClient::builder() let builder = HttpClient::builder()
@ -86,6 +90,7 @@ impl<F: FutureSpawner> ExternalNavigatorBackend<F> {
open_url_mode, open_url_mode,
socket_allowed, socket_allowed,
socket_mode, socket_mode,
content,
} }
} }
} }
@ -170,7 +175,7 @@ impl<F: FutureSpawner> NavigatorBackend for ExternalNavigatorBackend<F> {
fn fetch(&self, request: Request) -> OwnedFuture<Box<dyn SuccessResponse>, ErrorResponse> { fn fetch(&self, request: Request) -> OwnedFuture<Box<dyn SuccessResponse>, ErrorResponse> {
enum DesktopResponseBody { enum DesktopResponseBody {
/// The response's body comes from a file. /// The response's body comes from a file.
File(File), File(Result<Vec<u8>, std::io::Error>),
/// The response's body comes from the network. /// The response's body comes from the network.
/// ///
@ -195,13 +200,9 @@ impl<F: FutureSpawner> NavigatorBackend for ExternalNavigatorBackend<F> {
#[allow(clippy::await_holding_lock)] #[allow(clippy::await_holding_lock)]
fn body(self: Box<Self>) -> OwnedFuture<Vec<u8>, Error> { fn body(self: Box<Self>) -> OwnedFuture<Vec<u8>, Error> {
match self.response_body { match self.response_body {
DesktopResponseBody::File(mut file) => Box::pin(async move { DesktopResponseBody::File(file) => {
let mut body = vec![]; Box::pin(async move { file.map_err(|e| Error::FetchError(e.to_string())) })
file.read_to_end(&mut body) }
.map_err(|e| Error::FetchError(e.to_string()))?;
Ok(body)
}),
DesktopResponseBody::Network(response) => Box::pin(async move { DesktopResponseBody::Network(response) => Box::pin(async move {
let mut body = vec![]; let mut body = vec![];
response response
@ -228,17 +229,16 @@ impl<F: FutureSpawner> NavigatorBackend for ExternalNavigatorBackend<F> {
fn next_chunk(&mut self) -> OwnedFuture<Option<Vec<u8>>, Error> { fn next_chunk(&mut self) -> OwnedFuture<Option<Vec<u8>>, Error> {
match &mut self.response_body { match &mut self.response_body {
DesktopResponseBody::File(file) => { DesktopResponseBody::File(file) => {
let mut buf = vec![0; 4096]; let res = file
let res = file.read(&mut buf); .as_mut()
.map(std::mem::take)
.map_err(|e| Error::FetchError(e.to_string()));
Box::pin(async move { Box::pin(async move {
match res { match res {
Ok(count) if count > 0 => { Ok(bytes) if !bytes.is_empty() => Ok(Some(bytes)),
buf.resize(count, 0);
Ok(Some(buf))
}
Ok(_) => Ok(None), Ok(_) => Ok(None),
Err(e) => Err(Error::FetchError(e.to_string())), Err(e) => Err(e),
} }
}) })
} }
@ -276,7 +276,9 @@ impl<F: FutureSpawner> NavigatorBackend for ExternalNavigatorBackend<F> {
fn expected_length(&self) -> Result<Option<u64>, Error> { fn expected_length(&self) -> Result<Option<u64>, Error> {
match &self.response_body { match &self.response_body {
DesktopResponseBody::File(file) => Ok(Some(file.metadata()?.len())), DesktopResponseBody::File(file) => {
Ok(file.as_ref().map(|file| file.len() as u64).ok())
}
DesktopResponseBody::Network(response) => { DesktopResponseBody::Network(response) => {
let response = response.lock().expect("no recursive locks"); let response = response.lock().expect("no recursive locks");
let content_length = response.headers().get("Content-Length"); let content_length = response.headers().get("Content-Length");
@ -306,41 +308,46 @@ impl<F: FutureSpawner> NavigatorBackend for ExternalNavigatorBackend<F> {
let client = self.client.clone(); let client = self.client.clone();
match processed_url.scheme() { match processed_url.scheme() {
"file" => Box::pin(async move { "file" => {
// We send the original url (including query parameters) let content = self.content.clone();
// back to ruffle_core in the `Response` Box::pin(async move {
let response_url = processed_url.clone(); // We send the original url (including query parameters)
// Flash supports query parameters with local urls. // back to ruffle_core in the `Response`
// SwfMovie takes care of exposing those to ActionScript - let response_url = processed_url.clone();
// when we actually load a filesystem url, strip them out. // Flash supports query parameters with local urls.
processed_url.set_query(None); // SwfMovie takes care of exposing those to ActionScript -
// when we actually load a filesystem url, strip them out.
processed_url.set_query(None);
let path = match processed_url.to_file_path() { let path = match processed_url.to_file_path() {
Ok(path) => path, Ok(path) => path,
Err(_) => { Err(_) => {
return create_specific_fetch_error( return create_specific_fetch_error(
"Unable to create path out of URL", "Unable to create path out of URL",
response_url.as_str(), response_url.as_str(),
"", "",
); );
}
} }
}; .to_string_lossy()
.to_string();
let contents = std::fs::File::open(&path).or_else(|e| { let contents = content.get_local_file(&path).or_else(|e| {
if cfg!(feature = "sandbox") { if cfg!(feature = "sandbox") {
use rfd::FileDialog; use rfd::FileDialog;
let parent_path = Path::new(&path).parent().unwrap_or_else(|| Path::new(&path));
if e.kind() == ErrorKind::PermissionDenied { if e.kind() == ErrorKind::PermissionDenied {
let attempt_sandbox_open = MessageDialog::new() let attempt_sandbox_open = MessageDialog::new()
.set_level(MessageLevel::Warning) .set_level(MessageLevel::Warning)
.set_description(format!("The current movie is attempting to read files stored in {}.\n\nTo allow it to do so, click Yes, and then Open to grant read access to that directory.\n\nOtherwise, click No to deny access.", path.parent().unwrap_or(&path).to_string_lossy())) .set_description(format!("The current movie is attempting to read files stored in {}.\n\nTo allow it to do so, click Yes, and then Open to grant read access to that directory.\n\nOtherwise, click No to deny access.", parent_path.to_string_lossy()))
.set_buttons(MessageButtons::YesNo) .set_buttons(MessageButtons::YesNo)
.show() == MessageDialogResult::Yes; .show() == MessageDialogResult::Yes;
if attempt_sandbox_open { if attempt_sandbox_open {
FileDialog::new().set_directory(&path).pick_folder(); FileDialog::new().set_directory(parent_path).pick_folder();
return std::fs::File::open(&path); return content.get_local_file(&path);
} }
} }
} }
@ -348,26 +355,16 @@ impl<F: FutureSpawner> NavigatorBackend for ExternalNavigatorBackend<F> {
Err(e) Err(e)
}); });
let file = match contents { let response: Box<dyn SuccessResponse> = Box::new(DesktopResponse {
Ok(file) => file, url: response_url.to_string(),
Err(e) => { response_body: DesktopResponseBody::File(contents),
return create_specific_fetch_error( status: 0,
"Can't open file", redirected: false,
response_url.as_str(), });
e,
);
}
};
let response: Box<dyn SuccessResponse> = Box::new(DesktopResponse { Ok(response)
url: response_url.to_string(), })
response_body: DesktopResponseBody::File(file), }
status: 0,
redirected: false,
});
Ok(response)
}),
_ => Box::pin(async move { _ => Box::pin(async move {
let client = client.ok_or_else(|| ErrorResponse { let client = client.ok_or_else(|| ErrorResponse {
url: processed_url.to_string(), url: processed_url.to_string(),
@ -692,8 +689,9 @@ mod tests {
} }
fn new_test_backend(socket_allow: bool) -> ExternalNavigatorBackend<TestFutureSpawner> { fn new_test_backend(socket_allow: bool) -> ExternalNavigatorBackend<TestFutureSpawner> {
let url = Url::parse("https://example.com/path/").unwrap();
ExternalNavigatorBackend::new( ExternalNavigatorBackend::new(
Url::parse("https://example.com/path/").unwrap(), url.clone(),
TestFutureSpawner, TestFutureSpawner,
None, None,
false, false,
@ -704,6 +702,7 @@ mod tests {
} else { } else {
SocketMode::Deny SocketMode::Deny
}, },
Arc::new(PlayingContent::DirectFile(url)),
) )
} }

View File

@ -15,11 +15,16 @@ use ruffle_core::{
DefaultFont, LoadBehavior, Player, PlayerBuilder, PlayerEvent, PlayerRuntime, StageAlign, DefaultFont, LoadBehavior, Player, PlayerBuilder, PlayerEvent, PlayerRuntime, StageAlign,
StageScaleMode, StageScaleMode,
}; };
use ruffle_frontend_utils::bundle::info::BUNDLE_INFORMATION_FILENAME;
use ruffle_frontend_utils::bundle::Bundle;
use ruffle_render::backend::RenderBackend; use ruffle_render::backend::RenderBackend;
use ruffle_render::quality::StageQuality; use ruffle_render::quality::StageQuality;
use ruffle_render_wgpu::backend::WgpuRenderBackend; use ruffle_render_wgpu::backend::WgpuRenderBackend;
use ruffle_render_wgpu::descriptors::Descriptors; use ruffle_render_wgpu::descriptors::Descriptors;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::fmt::{Debug, Formatter};
use std::io::Read;
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc; use std::rc::Rc;
use std::sync::{Arc, Mutex, MutexGuard}; use std::sync::{Arc, Mutex, MutexGuard};
@ -89,6 +94,54 @@ impl From<&GlobalPreferences> for PlayerOptions {
} }
} }
pub enum PlayingContent {
DirectFile(Url),
Bundle(Url, Bundle),
}
impl Debug for PlayingContent {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
PlayingContent::DirectFile(url) => f
.debug_tuple("PlayingContent::DirectFile")
.field(url)
.finish(),
PlayingContent::Bundle(url, _) => f
.debug_tuple("PlayingContent::Bundle")
.field(url)
.field(&"_")
.finish(),
}
}
}
impl PlayingContent {
pub fn initial_swf_url(&self) -> &Url {
match self {
PlayingContent::DirectFile(url) => url,
PlayingContent::Bundle(_, bundle) => &bundle.information().url,
}
}
pub fn name(&self) -> String {
match self {
PlayingContent::DirectFile(url) => crate::util::url_to_readable_name(url).to_string(),
PlayingContent::Bundle(_, bundle) => bundle.information().name.to_string(),
}
}
pub fn get_local_file(&self, path: &str) -> Result<Vec<u8>, std::io::Error> {
match self {
PlayingContent::DirectFile(_) => {
let mut result = vec![];
std::fs::File::open(path)?.read_to_end(&mut result)?;
Ok(result)
}
PlayingContent::Bundle(_, bundle) => bundle.source().read_content(path),
}
}
}
/// Represents a current Player and any associated state with that player, /// Represents a current Player and any associated state with that player,
/// which may be lost when this Player is closed (dropped) /// which may be lost when this Player is closed (dropped)
struct ActivePlayer { struct ActivePlayer {
@ -119,7 +172,41 @@ impl ActivePlayer {
} }
}; };
let mut content = PlayingContent::DirectFile(movie_url.clone());
if movie_url.scheme() == "file" {
if let Ok(path) = movie_url.to_file_path() {
if path.is_file()
&& path.file_name() == Some(OsStr::new(BUNDLE_INFORMATION_FILENAME))
{
if let Some(bundle_dir) = path.parent() {
match Bundle::from_path(bundle_dir) {
Ok(bundle) => {
if bundle.warnings().is_empty() {
tracing::info!("Opening bundle at {bundle_dir:?}");
} else {
// TODO: Show warnings to user (toast?)
tracing::warn!(
"Opening bundle at {bundle_dir:?} with warnings"
);
for warning in bundle.warnings() {
tracing::warn!("{warning}");
}
}
content = PlayingContent::Bundle(movie_url.clone(), bundle);
}
Err(e) => {
// TODO: Visible popup when a bundle (or regular file) fails to open
tracing::error!("Couldn't open bundle at {bundle_dir:?}: {e}");
}
}
}
}
}
}
let (executor, future_spawner) = WinitAsyncExecutor::new(event_loop.clone()); let (executor, future_spawner) = WinitAsyncExecutor::new(event_loop.clone());
let movie_url = content.initial_swf_url().clone();
let readable_name = content.name();
let navigator = ExternalNavigatorBackend::new( let navigator = ExternalNavigatorBackend::new(
opt.base.to_owned().unwrap_or_else(|| movie_url.clone()), opt.base.to_owned().unwrap_or_else(|| movie_url.clone()),
future_spawner, future_spawner,
@ -128,6 +215,7 @@ impl ActivePlayer {
opt.open_url_mode, opt.open_url_mode,
opt.socket_allowed.clone(), opt.socket_allowed.clone(),
opt.tcp_connections, opt.tcp_connections,
Arc::new(content),
); );
if cfg!(feature = "software_video") { if cfg!(feature = "software_video") {
@ -189,11 +277,9 @@ impl ActivePlayer {
.with_avm2_optimizer_enabled(opt.avm2_optimizer_enabled); .with_avm2_optimizer_enabled(opt.avm2_optimizer_enabled);
let player = builder.build(); let player = builder.build();
let readable_name = crate::util::url_to_readable_name(movie_url);
window.set_title(&format!("Ruffle - {readable_name}")); window.set_title(&format!("Ruffle - {readable_name}"));
SWF_INFO.with(|i| *i.borrow_mut() = Some(readable_name.into_owned())); SWF_INFO.with(|i| *i.borrow_mut() = Some(readable_name));
let on_metadata = move |swf_header: &ruffle_core::swf::HeaderExt| { let on_metadata = move |swf_header: &ruffle_core::swf::HeaderExt| {
let _ = event_loop.send_event(RuffleEvent::OnMetadata(swf_header.clone())); let _ = event_loop.send_event(RuffleEvent::OnMetadata(swf_header.clone()));

View File

@ -1,7 +1,7 @@
# Ruffle Bundle (.ruf) format specification # Ruffle Bundle (.ruf) format specification
A Ruffle Bundle is an easy way to package and share Flash games and any assets that are required to make the game work. A Ruffle Bundle is an easy way to package and share Flash games and any assets that are required to make the game work.
A bundle can be a directory or a renamed zip file, and must contain at minimum a `bundle.toml` file. A bundle can be a directory or a renamed zip file, and must contain at minimum a `ruffle-bundle.toml` file.
<!-- TOC --> <!-- TOC -->
* [Ruffle Bundle (.ruf) format specification](#ruffle-bundle-ruf-format-specification) * [Ruffle Bundle (.ruf) format specification](#ruffle-bundle-ruf-format-specification)

View File

@ -7,7 +7,7 @@ impl BundleSourceImpl for Path {
type Read = File; type Read = File;
fn read_file(&self, path: &str) -> Result<Self::Read, Error> { fn read_file(&self, path: &str) -> Result<Self::Read, Error> {
let potential_path = self.join(path); let potential_path = self.join(path.strip_prefix('/').unwrap_or(path));
if !potential_path.starts_with(self) { if !potential_path.starts_with(self) {
return Err(Error::from(ErrorKind::NotFound)); return Err(Error::from(ErrorKind::NotFound));
} }
@ -16,7 +16,7 @@ impl BundleSourceImpl for Path {
fn read_content(&self, path: &str) -> Result<Self::Read, Error> { fn read_content(&self, path: &str) -> Result<Self::Read, Error> {
let root = self.join("content"); let root = self.join("content");
let potential_path = root.join(path); let potential_path = root.join(path.strip_prefix('/').unwrap_or(path));
if !potential_path.starts_with(root) { if !potential_path.starts_with(root) {
return Err(Error::from(ErrorKind::NotFound)); return Err(Error::from(ErrorKind::NotFound));
} }
@ -136,6 +136,25 @@ mod tests {
assert_eq!(result.as_deref(), Ok("Fancy!")) assert_eq!(result.as_deref(), Ok("Fancy!"))
} }
#[test]
fn read_content_works_with_absolute_path() {
let tmp_dir = tempdir().unwrap();
let _ = std::fs::create_dir(tmp_dir.path().join("content"));
let _ = std::fs::write(tmp_dir.path().join("content/some_file.txt"), "Fancy!");
let result = tmp_dir
.path()
.read_content("/some_file.txt")
.map_err(|e| e.to_string())
.map(|mut f| {
let mut result = String::new();
let _ = f.read_to_string(&mut result);
result
});
drop(tmp_dir);
assert_eq!(result.as_deref(), Ok("Fancy!"))
}
#[test] #[test]
fn read_content_outside_content_directory() { fn read_content_outside_content_directory() {
let tmp_dir = tempdir().unwrap(); let tmp_dir = tempdir().unwrap();