frontend-utils: Support .ruf files
This commit is contained in:
parent
4632a14376
commit
bf7a88d63f
|
@ -49,6 +49,17 @@ version = "1.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.11"
|
||||
|
@ -488,6 +499,12 @@ version = "0.22.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.69.4"
|
||||
|
@ -658,6 +675,27 @@ version = "1.6.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.11+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.12.4"
|
||||
|
@ -773,6 +811,16 @@ dependencies = [
|
|||
"windows-targets 0.52.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.7.0"
|
||||
|
@ -928,6 +976,12 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
|
@ -1383,6 +1437,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2492,6 +2547,15 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.9"
|
||||
|
@ -2670,6 +2734,15 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "insta"
|
||||
version = "1.38.0"
|
||||
|
@ -3696,6 +3769,17 @@ dependencies = [
|
|||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.14"
|
||||
|
@ -3708,6 +3792,18 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
"password-hash",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.1"
|
||||
|
@ -4290,6 +4386,7 @@ dependencies = [
|
|||
"toml_edit 0.22.9",
|
||||
"tracing",
|
||||
"url",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -4980,6 +5077,12 @@ dependencies = [
|
|||
"ruffle_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
|
||||
|
||||
[[package]]
|
||||
name = "swf"
|
||||
version = "0.2.0"
|
||||
|
@ -6683,6 +6786,55 @@ dependencies = [
|
|||
"syn 2.0.58",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"byteorder",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
"hmac",
|
||||
"pbkdf2",
|
||||
"sha1",
|
||||
"time",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.11.2+zstd.1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "5.0.2+zstd.1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.10+zstd.1.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c253a4914af5bafc8fa8c86ee400827e83cf6ec01195ec1f1ed8441bf00d65aa"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "4.0.2"
|
||||
|
|
|
@ -52,7 +52,7 @@ pub struct ExternalNavigatorBackend<F: FutureSpawner> {
|
|||
|
||||
open_url_mode: OpenURLMode,
|
||||
|
||||
content: Arc<PlayingContent>,
|
||||
content: Rc<PlayingContent>,
|
||||
}
|
||||
|
||||
impl<F: FutureSpawner> ExternalNavigatorBackend<F> {
|
||||
|
@ -66,7 +66,7 @@ impl<F: FutureSpawner> ExternalNavigatorBackend<F> {
|
|||
open_url_mode: OpenURLMode,
|
||||
socket_allowed: HashSet<String>,
|
||||
socket_mode: SocketMode,
|
||||
content: Arc<PlayingContent>,
|
||||
content: Rc<PlayingContent>,
|
||||
) -> Self {
|
||||
let proxy = proxy.and_then(|url| url.as_str().parse().ok());
|
||||
let builder = HttpClient::builder()
|
||||
|
@ -702,7 +702,7 @@ mod tests {
|
|||
} else {
|
||||
SocketMode::Deny
|
||||
},
|
||||
Arc::new(PlayingContent::DirectFile(url)),
|
||||
Rc::new(PlayingContent::DirectFile(url)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -15,14 +15,13 @@ use ruffle_core::{
|
|||
DefaultFont, LoadBehavior, Player, PlayerBuilder, PlayerEvent, PlayerRuntime, StageAlign,
|
||||
StageScaleMode,
|
||||
};
|
||||
use ruffle_frontend_utils::bundle::info::BUNDLE_INFORMATION_FILENAME;
|
||||
use ruffle_frontend_utils::bundle::Bundle;
|
||||
use ruffle_frontend_utils::bundle::source::BundleSourceError;
|
||||
use ruffle_frontend_utils::bundle::{Bundle, BundleError};
|
||||
use ruffle_render::backend::RenderBackend;
|
||||
use ruffle_render::quality::StageQuality;
|
||||
use ruffle_render_wgpu::backend::WgpuRenderBackend;
|
||||
use ruffle_render_wgpu::descriptors::Descriptors;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt::{Debug, Formatter};
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
|
@ -175,30 +174,26 @@ 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}");
|
||||
match Bundle::from_path(&path) {
|
||||
Ok(bundle) => {
|
||||
if bundle.warnings().is_empty() {
|
||||
tracing::info!("Opening bundle at {path:?}");
|
||||
} else {
|
||||
// TODO: Show warnings to user (toast?)
|
||||
tracing::warn!("Opening bundle at {path:?} with warnings");
|
||||
for warning in bundle.warnings() {
|
||||
tracing::warn!("{warning}");
|
||||
}
|
||||
}
|
||||
content = PlayingContent::Bundle(movie_url.clone(), bundle);
|
||||
}
|
||||
Err(BundleError::BundleDoesntExist)
|
||||
| Err(BundleError::InvalidSource(BundleSourceError::UnknownSource)) => {
|
||||
// Do nothing and carry on opening it as a swf - this likely isn't a bundle at all
|
||||
}
|
||||
Err(e) => {
|
||||
// TODO: Visible popup when a bundle (or regular file) fails to open
|
||||
tracing::error!("Couldn't open bundle at {path:?}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -215,7 +210,7 @@ impl ActivePlayer {
|
|||
opt.open_url_mode,
|
||||
opt.socket_allowed.clone(),
|
||||
opt.tcp_connections,
|
||||
Arc::new(content),
|
||||
Rc::new(content),
|
||||
);
|
||||
|
||||
if cfg!(feature = "software_video") {
|
||||
|
|
|
@ -245,7 +245,7 @@ pub fn url_to_readable_name(url: &Url) -> Cow<'_, str> {
|
|||
|
||||
fn actually_pick_file(dir: Option<PathBuf>) -> Option<PathBuf> {
|
||||
let mut dialog = FileDialog::new()
|
||||
.add_filter("Flash Files", &["swf", "spl"])
|
||||
.add_filter("Flash Files", &["swf", "spl", "ruf"])
|
||||
.add_filter("All Files", &["*"])
|
||||
.set_title("Load a Flash File");
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ toml_edit = { version = "0.22.9", features = ["parse"] }
|
|||
url = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
zip = "0.6.6"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
|
@ -1,7 +1,12 @@
|
|||
use crate::bundle::info::BUNDLE_INFORMATION_FILENAME;
|
||||
use crate::bundle::source::zip::ZipSource;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::File;
|
||||
use std::io::{Error, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub mod directory;
|
||||
mod zip;
|
||||
|
||||
trait BundleSourceImpl {
|
||||
type Read: Read;
|
||||
|
@ -15,21 +20,46 @@ trait BundleSourceImpl {
|
|||
|
||||
pub enum BundleSource {
|
||||
Directory(PathBuf),
|
||||
ZipFile(ZipSource<File>),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BundleSourceError {
|
||||
#[error("Unknown bundle source")]
|
||||
UnknownSource,
|
||||
|
||||
#[error("Invalid or corrupt zip")]
|
||||
InvalidZip,
|
||||
|
||||
#[error("IO error opening file")]
|
||||
Io(#[from] Error),
|
||||
}
|
||||
|
||||
impl BundleSource {
|
||||
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, BundleSourceError> {
|
||||
let path = path.as_ref();
|
||||
if path.is_dir() {
|
||||
|
||||
// Opening a directory which happens to contain a ruffle-bundle.toml file, the bundle is this directory
|
||||
if path.is_dir() && path.join(BUNDLE_INFORMATION_FILENAME).is_file() {
|
||||
return Ok(Self::Directory(path.to_owned()));
|
||||
}
|
||||
|
||||
if path.is_file() {
|
||||
// Opening a ruffle-bundle.toml, the bundle is the parent directory
|
||||
if path.file_name() == Some(OsStr::new(BUNDLE_INFORMATION_FILENAME)) {
|
||||
return Ok(Self::Directory(path.to_owned()));
|
||||
}
|
||||
|
||||
// Opening a .ruf file, the bundle is that file viewed as a zip
|
||||
if path.extension() == Some(OsStr::new("ruf")) {
|
||||
return if let Ok(zip) = ZipSource::open(File::open(path)?) {
|
||||
Ok(Self::ZipFile(zip))
|
||||
} else {
|
||||
Err(BundleSourceError::InvalidZip)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Err(BundleSourceError::UnknownSource)
|
||||
}
|
||||
|
||||
|
@ -42,6 +72,7 @@ impl BundleSource {
|
|||
file.read_to_end(&mut data)?;
|
||||
Ok(data)
|
||||
}
|
||||
BundleSource::ZipFile(zip) => zip.read_file(path).map(|cursor| cursor.into_inner()),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -54,6 +85,7 @@ impl BundleSource {
|
|||
file.read_to_end(&mut data)?;
|
||||
Ok(data)
|
||||
}
|
||||
BundleSource::ZipFile(zip) => zip.read_content(path).map(|cursor| cursor.into_inner()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,109 @@
|
|||
use std::cell::RefCell;
|
||||
use std::io::{Cursor, Error, ErrorKind, Read, Seek};
|
||||
use zip::result::ZipError;
|
||||
|
||||
use zip::ZipArchive;
|
||||
|
||||
use crate::bundle::source::BundleSourceImpl;
|
||||
|
||||
pub struct ZipSource<R: Read + Seek>(RefCell<ZipArchive<R>>);
|
||||
|
||||
impl<R: Read + Seek> ZipSource<R> {
|
||||
pub fn open(reader: R) -> Result<Self, ZipError> {
|
||||
Ok(Self(RefCell::new(ZipArchive::new(reader)?)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read + Seek> BundleSourceImpl for ZipSource<R> {
|
||||
type Read = Cursor<Vec<u8>>;
|
||||
|
||||
fn read_file(&self, path: &str) -> Result<Self::Read, Error> {
|
||||
let mut self_ref = self.0.borrow_mut();
|
||||
let mut result = self_ref
|
||||
.by_name(path.strip_prefix('/').unwrap_or(path))
|
||||
.map_err(|e| match e {
|
||||
ZipError::Io(e) => e,
|
||||
ZipError::InvalidArchive(_) => e.into(),
|
||||
ZipError::UnsupportedArchive(_) => e.into(),
|
||||
ZipError::FileNotFound => Error::from(ErrorKind::NotFound),
|
||||
})?;
|
||||
let mut buf = vec![];
|
||||
result.read_to_end(&mut buf)?;
|
||||
Ok(Cursor::new(buf))
|
||||
}
|
||||
|
||||
fn read_content(&self, path: &str) -> Result<Self::Read, Error> {
|
||||
let path = path.strip_prefix('/').unwrap_or(path);
|
||||
self.read_file(&format!("content/{path}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{Cursor, ErrorKind, Read};
|
||||
|
||||
use zip::result::ZipError;
|
||||
|
||||
use crate::bundle::source::zip::ZipSource;
|
||||
use crate::bundle::source::BundleSourceImpl;
|
||||
|
||||
#[test]
|
||||
fn open_not_a_zip() {
|
||||
assert!(matches!(
|
||||
ZipSource::open(Cursor::new(&[0, 1, 2, 3])),
|
||||
Err(ZipError::InvalidArchive(_))
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_file_not_found() {
|
||||
let not_a_zip = include_bytes!("./test-assets/empty.zip");
|
||||
let source = ZipSource::open(Cursor::new(not_a_zip)).unwrap();
|
||||
assert!(matches!(
|
||||
source.read_file("some_file"),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_file_valid() {
|
||||
let not_a_zip = include_bytes!("./test-assets/just-bundle.zip");
|
||||
let source = ZipSource::open(Cursor::new(not_a_zip)).unwrap();
|
||||
let mut file = source.read_file("ruffle-bundle.toml").unwrap();
|
||||
let mut string = String::new();
|
||||
file.read_to_string(&mut string).unwrap();
|
||||
assert_eq!("[bundle]\nname = \"Ruffle Logo Animation\"\nurl = \"https://ruffle.rs/demo/logo-anim.swf\"", string);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_content_not_found() {
|
||||
let not_a_zip = include_bytes!("./test-assets/empty.zip");
|
||||
let source = ZipSource::open(Cursor::new(not_a_zip)).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
source.read_content("some_file"),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_content_not_in_content_dir() {
|
||||
let not_a_zip = include_bytes!("./test-assets/just-bundle.zip");
|
||||
let source = ZipSource::open(Cursor::new(not_a_zip)).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
source.read_content("../ruffle-bundle.toml"),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_content_valid() {
|
||||
let not_a_zip = include_bytes!("./test-assets/bundle-and-content.xip");
|
||||
let source = ZipSource::open(Cursor::new(not_a_zip)).unwrap();
|
||||
let mut file = source.read_content("/foo.txt").unwrap();
|
||||
let mut string = String::new();
|
||||
file.read_to_string(&mut string).unwrap();
|
||||
assert_eq!("Hello world!\n", string);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue