OneLauncher-Cracked/packages/oneclient_cluster/src/screenshots/mod.rs
Maciek 0ab544f265
feat: custom cluster instances (#859)
* 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>
2026-09-29 19:08:21 +02:00

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());
}
}