Files
SpotiFLAC-Mobile/rust_backend/crates/extensions/src/file_host.rs
T

312 lines
12 KiB
Rust

use crate::binary;
use crate::files::{ExtensionFiles, MAX_READ, READ_LIMIT_ERROR};
use crate::runtime::{Control, ExtensionServices};
use cap_std::fs::OpenOptions;
use rquickjs::{ArrayBuffer, Ctx, Function, Object, Value};
use std::io::{Read, Seek, SeekFrom, Write};
use std::sync::Arc;
pub(crate) fn register<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<()> {
host.set("filesEnabled", services.files.is_some())?;
let Some(files) = &services.files else {
return Ok(());
};
let files = Arc::clone(files);
host.set(
"fileCall",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>,
method: String,
path: String,
data: Value<'js>,
options: Value<'js>,
callback: Value<'js>| {
let check = || control.check().map_err(|error| error.to_string());
let result = call(
&ctx, &files, &method, &path, data, options, callback, &check,
);
if method == "exists" {
return Ok::<_, rquickjs::Error>(Value::new_bool(ctx, result.is_ok()));
}
match result {
Ok(object) => Ok(object.into_value()),
Err(error) => {
let object = Object::new(ctx)?;
object.set("success", false)?;
object.set("error", error)?;
Ok(object.into_value())
}
}
},
)?,
)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn call<'js>(
ctx: &Ctx<'js>,
files: &ExtensionFiles,
method: &str,
path: &str,
data: Value<'js>,
options: Value<'js>,
callback: Value<'js>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Object<'js>, String> {
check()?;
let path = files.resolve(path)?;
let options = Options(options.as_object());
let result = Object::new(ctx.clone()).map_err(js_error)?;
result.set("success", true).map_err(js_error)?;
match method {
"exists" | "getSize" => {
let info = path.metadata().map_err(io_error)?;
if method == "getSize" {
result.set("size", info.len()).map_err(js_error)?;
}
}
"delete" => {
let _guard = files.lock(&path, check)?;
path.remove().map_err(io_error)?;
}
"read" | "readBytes" => {
let offset = if method == "read" {
0
} else {
options.int("offset", 0)?
};
let requested = options.int("length", -1)?;
let encoding = options.text("encoding", "base64")?;
if offset < 0 {
return Err("offset must be >= 0".into());
}
let mut input = path.open(OpenOptions::new().read(true)).map_err(io_error)?;
let size = input.metadata().map_err(io_error)?.len();
let offset = (offset as u64).min(size);
let mut bytes;
if method == "read" {
bytes = Vec::new();
let mut buffer = [0; 64 << 10];
while bytes.len() <= MAX_READ {
check()?;
let capacity = buffer.len().min(MAX_READ + 1 - bytes.len());
let count = input.read(&mut buffer[..capacity]).map_err(io_error)?;
if count == 0 {
break;
}
bytes.extend_from_slice(&buffer[..count]);
}
if bytes.len() > MAX_READ {
return Err(READ_LIMIT_ERROR.into());
}
result
.set("data", crate::host::decode_go_utf8_owned(bytes))
.map_err(js_error)?;
} else {
input
.seek(SeekFrom::Start(offset))
.map_err(|error| format!("failed to seek file: {error}"))?;
let length = if requested < 0 {
size - offset
} else {
requested as u64
};
if length > MAX_READ as u64 {
return Err(READ_LIMIT_ERROR.into());
}
bytes = vec![0; length.min(size - offset) as usize];
let count = read_chunk(&mut input, &mut bytes, check)
.map_err(|error| format!("failed to read file: {error}"))?;
bytes.truncate(count);
if matches!(encoding.trim().to_lowercase().as_str(), "bytes" | "raw") {
result
.set(
"data",
ArrayBuffer::new_copy(ctx.clone(), &bytes).map_err(js_error)?,
)
.map_err(js_error)?;
} else {
result
.set("data", binary::encode(&bytes, &encoding)?)
.map_err(js_error)?;
}
result.set("bytes_read", bytes.len()).map_err(js_error)?;
result.set("offset", offset).map_err(js_error)?;
result.set("size", size).map_err(js_error)?;
result
.set("eof", offset + bytes.len() as u64 >= size)
.map_err(js_error)?;
}
}
"write" | "writeBytes" => {
let append = options.boolean("append", false)?;
let truncate = options.boolean("truncate", false)?;
let has_offset = options
.0
.map(|object| object.contains_key("offset"))
.transpose()
.map_err(js_error)?
.unwrap_or(false);
let offset = options.int("offset", 0)?;
if append && has_offset {
return Err("append and offset cannot be used together".into());
}
if offset < 0 {
return Err("offset must be >= 0".into());
}
let encoding = if method == "write" {
"utf8".into()
} else {
options.text("encoding", "base64")?
};
let bytes = binary::decode_value(data, &encoding)?;
let _guard = files.lock(&path, check)?;
path.mkdir_parent()
.map_err(|error| format!("failed to create directory: {error}"))?;
if method == "write" {
let mut output = path.stage().map_err(io_error)?;
write_chunks(&mut output.file, &bytes, check)?;
output.publish(check)?;
} else {
let mut output = path
.open(
OpenOptions::new()
.create(true)
.write(true)
.append(append)
.truncate(truncate),
)
.map_err(io_error)?;
if has_offset {
output
.seek(SeekFrom::Start(offset as u64))
.map_err(|error| format!("failed to seek file: {error}"))?;
}
write_chunks(&mut output, &bytes, check)?;
result.set("bytes_written", bytes.len()).map_err(js_error)?;
result
.set("size", output.metadata().map_err(io_error)?.len())
.map_err(js_error)?;
}
result.set("path", path.display()).map_err(js_error)?;
}
"copy" | "move" | "transformPatternedBlocks" => {
let destination = data
.as_string()
.ok_or("destination path is required")?
.to_string()
.map_err(js_error)?;
let destination = files.resolve(&destination)?;
if method == "transformPatternedBlocks" {
return crate::file_transform::transform(
ctx,
files,
&path,
&destination,
options,
callback,
check,
);
}
let _guard = files.lock(&destination, check)?;
if method == "move" {
destination
.mkdir_parent()
.map_err(|error| format!("failed to create directory: {error}"))?;
check()?;
path.rename_to(&destination)
.map_err(|error| format!("failed to move file: {error}"))?;
} else {
let mut input = path
.open(OpenOptions::new().read(true))
.map_err(|error| format!("failed to read source: {error}"))?;
destination
.mkdir_parent()
.map_err(|error| format!("failed to create directory: {error}"))?;
let mut output = destination
.open(OpenOptions::new().write(true).create(true).truncate(true))
.map_err(|error| format!("failed to open destination: {error}"))?;
let mut buffer = [0; 64 << 10];
loop {
let count = read_chunk(&mut input, &mut buffer, check)
.map_err(|error| format!("failed to copy file: {error}"))?;
if count == 0 {
break;
}
write_chunks(&mut output, &buffer[..count], check)
.map_err(|error| format!("failed to copy file: {error}"))?;
}
}
result
.set("path", destination.display())
.map_err(js_error)?;
}
_ => return Err("unknown file operation".into()),
}
check()?;
Ok(result)
}
pub(crate) struct Options<'a, 'js>(pub Option<&'a Object<'js>>);
impl Options<'_, '_> {
pub fn text(&self, key: &str, fallback: &str) -> Result<String, String> {
binary::option_string(self.0, key, fallback)
}
pub fn int(&self, key: &str, fallback: i64) -> Result<i64, String> {
self.0.map_or(Ok(fallback), |object| {
binary::option_i64(object, key, fallback)
})
}
pub fn boolean(&self, key: &str, fallback: bool) -> Result<bool, String> {
self.0.map_or(Ok(fallback), |object| {
binary::option_bool(object, key, fallback)
})
}
}
pub(crate) fn read_chunk(
input: &mut impl Read,
buffer: &mut [u8],
check: &dyn Fn() -> Result<(), String>,
) -> Result<usize, String> {
let mut total = 0;
while total < buffer.len() {
check()?;
let end = buffer.len().min(total + (64 << 10));
match input.read(&mut buffer[total..end]) {
Ok(0) => break,
Ok(count) => total += count,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error.to_string()),
}
}
Ok(total)
}
pub(crate) fn write_chunks(
output: &mut impl Write,
bytes: &[u8],
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
for chunk in bytes.chunks(64 << 10) {
check()?;
output.write_all(chunk).map_err(io_error)?;
}
check()
}
pub(crate) fn js_error(error: rquickjs::Error) -> String {
error.to_string()
}
fn io_error(error: std::io::Error) -> String {
error.to_string()
}