* feat: added adding new instances of a cluster with 'start from scratch' option enabling user to create a vanilla/modded cluster; fixed couple pre-existing bugs that wouldn't let other loaders boot up * feat: fully implemented cluster creation modal design * chore: refactoring * feat: expanded the tags section on customization step; found that fabric doesnt work with 1.8.9 * feat: added gallery covers handling for new cluster instances * feat: added 'redirecting' from fabric to orinthe on versions pre-1.14 * feat: added java-version-override * feat: added loader types migrations (right now they dont migrate mods because there are no bundles) * chore: wider loader dropdown for multiloader cluster (if it will be used in the future) * chore: fixed more issues with isolated and shared dir, with migrations and instances naming * chore: refactor; taken the loader version dropdown from version picker view back to loader selector * chore: deleted Quilt from UI --------- Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
171 lines
4.8 KiB
Rust
171 lines
4.8 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use bytes::Bytes;
|
|
use chrono::{DateTime, Utc};
|
|
use thiserror::Error;
|
|
|
|
use crate::cluster::Cluster;
|
|
use crate::error::ClusterResult;
|
|
use oneclient_common::paths;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum ScreenshotsError {
|
|
#[error(transparent)]
|
|
Io(#[from] std::io::Error),
|
|
#[error("path is not inside a cluster directory: {0}")]
|
|
InvalidPath(String),
|
|
#[error("failed to move screenshot to trash: {0}")]
|
|
Trash(String),
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct ScreenshotInfo {
|
|
pub name: String,
|
|
pub size_bytes: u64,
|
|
pub created: DateTime<Utc>,
|
|
pub resolution: Option<(u32, u32)>,
|
|
pub path: PathBuf,
|
|
}
|
|
|
|
fn is_image(name: &str) -> bool {
|
|
let lower = name.to_ascii_lowercase();
|
|
lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")
|
|
}
|
|
|
|
fn ensure_in_clusters(path: &Path) -> ClusterResult<PathBuf> {
|
|
polyio::ensure_under(
|
|
path,
|
|
[paths::clusters_dir()?, paths::shared_minecraft_dir()?],
|
|
)?
|
|
.ok_or_else(|| ScreenshotsError::InvalidPath(path.display().to_string()).into())
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))]
|
|
pub fn list_cluster_screenshots(cluster: &Cluster) -> ClusterResult<Vec<ScreenshotInfo>> {
|
|
let dir = cluster.game_dir()?.join("screenshots");
|
|
let mut out = Vec::new();
|
|
|
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
|
return Ok(out);
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
|
continue;
|
|
};
|
|
|
|
if !is_image(name) {
|
|
continue;
|
|
}
|
|
|
|
let Ok(meta) = std::fs::metadata(&path) else {
|
|
continue;
|
|
};
|
|
|
|
if !meta.is_file() {
|
|
continue;
|
|
}
|
|
|
|
let created = meta
|
|
.created()
|
|
.or_else(|_| meta.modified())
|
|
.map(DateTime::<Utc>::from)
|
|
.unwrap_or_else(|_| Utc::now());
|
|
|
|
let resolution = image::image_dimensions(&path).ok();
|
|
|
|
out.push(ScreenshotInfo {
|
|
name: name.to_string(),
|
|
size_bytes: meta.len(),
|
|
created,
|
|
resolution,
|
|
path,
|
|
});
|
|
}
|
|
|
|
out.sort_by_key(|s| std::cmp::Reverse(s.created));
|
|
Ok(out)
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug")]
|
|
pub fn load_screenshot(path: &Path, max_edge: Option<u32>) -> ClusterResult<Bytes> {
|
|
load_picked_image(&ensure_in_clusters(path)?, max_edge)
|
|
}
|
|
|
|
pub fn load_picked_image(path: &Path, max_edge: Option<u32>) -> ClusterResult<Bytes> {
|
|
let raw = std::fs::read(path).map_err(ScreenshotsError::Io)?;
|
|
Ok(match max_edge {
|
|
Some(edge) => thumbnail(&raw, edge).unwrap_or_else(|| Bytes::from(raw)),
|
|
None => Bytes::from(raw),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn thumbnail(raw: &[u8], max_edge: u32) -> Option<Bytes> {
|
|
let img = image::load_from_memory(raw).ok()?;
|
|
if img.width().max(img.height()) <= max_edge {
|
|
return None;
|
|
}
|
|
let resized = img.resize(max_edge, max_edge, image::imageops::FilterType::Triangle);
|
|
|
|
let mut out = Vec::new();
|
|
let mut cursor = std::io::Cursor::new(&mut out);
|
|
resized
|
|
.write_to(&mut cursor, image::ImageFormat::Png)
|
|
.ok()?;
|
|
Some(Bytes::from(out))
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug")]
|
|
pub fn delete_screenshot(path: &Path) -> ClusterResult<()> {
|
|
let path = ensure_in_clusters(path)?;
|
|
|
|
// TODO maybe async?
|
|
match trash::delete(&path) {
|
|
Ok(()) => Ok(()),
|
|
Err(err) => Err(ScreenshotsError::Trash(err.to_string()).into()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn scratch_png(tag: &str) -> PathBuf {
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
static NEXT: AtomicU32 = AtomicU32::new(0);
|
|
|
|
let unique = NEXT.fetch_add(1, Ordering::Relaxed);
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"oneclient-picked-{tag}-{}-{unique}",
|
|
std::process::id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("cover.png");
|
|
|
|
let image = image::RgbImage::new(4, 3);
|
|
image.save(&path).unwrap();
|
|
|
|
path
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_the_user_picked_outside_the_launcher_folders_still_loads() {
|
|
let path = scratch_png("outside");
|
|
|
|
let bytes = load_picked_image(&path, None).expect("a picked file reads");
|
|
assert!(!bytes.is_empty());
|
|
|
|
assert!(
|
|
load_screenshot(&path, None).is_err(),
|
|
"the screenshot loader must keep refusing paths outside the launcher folders"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_picked_file_is_an_error_not_an_empty_image() {
|
|
let missing = std::env::temp_dir().join("oneclient-picked-missing-does-not-exist.png");
|
|
|
|
assert!(load_picked_image(&missing, None).is_err());
|
|
}
|
|
}
|