Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src-tauri/src/commands/migration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use crate::error::{AppResult, IpcResult, MutexResultExt};
use crate::mods::{BulkInstallResult, CslolModInfo, ModLibraryState};
use crate::patcher::PatcherState;
use crate::state::SettingsState;
use std::path::PathBuf;
use tauri::State;

use super::mods::reject_if_patcher_running;

/// Scan a cslol-manager directory for importable mods.
#[tauri::command]
pub fn scan_cslol_mods(directory: String) -> IpcResult<Vec<CslolModInfo>> {
let result: AppResult<Vec<CslolModInfo>> =
crate::mods::scan_cslol_directory(&PathBuf::from(directory));
result.into()
}

/// Import selected mods from a cslol-manager installation.
#[tauri::command]
pub fn import_cslol_mods(
directory: String,
selected_folders: Vec<String>,
library: State<ModLibraryState>,
settings: State<SettingsState>,
patcher: State<PatcherState>,
) -> IpcResult<BulkInstallResult> {
let result: AppResult<BulkInstallResult> = (|| {
reject_if_patcher_running(&patcher)?;
let settings = settings.0.lock().mutex_err()?.clone();
crate::mods::import_cslol_mods(
&library.0,
&settings,
&PathBuf::from(&directory),
&selected_folders,
)
})();
result.into()
}
2 changes: 2 additions & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
//! See `docs/ERROR_HANDLING.md` for details.

mod app;
mod migration;
mod mods;
mod patcher;
mod profiles;
Expand All @@ -25,6 +26,7 @@ mod shell;
mod workshop;

pub use app::*;
pub use migration::*;
pub use mods::*;
pub use patcher::*;
pub use profiles::*;
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/commands/mods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ pub fn get_storage_directory(
}

/// Reject the operation if the patcher is currently running.
fn reject_if_patcher_running(patcher: &State<PatcherState>) -> AppResult<()> {
pub(super) fn reject_if_patcher_running(patcher: &State<PatcherState>) -> AppResult<()> {
let state = patcher.0.lock().mutex_err()?;
if state.is_running() {
return Err(AppError::PatcherRunning);
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ pub enum ErrorCode {
Wad,
/// Operation blocked because the patcher is running
PatcherRunning,
/// ZIP error
Zip,
}

/// Structured error response sent over IPC.
Expand Down Expand Up @@ -190,6 +192,9 @@ pub enum AppError {

#[error("Cannot modify mods while the patcher is running")]
PatcherRunning,

#[error("ZIP error: {0}")]
ZipError(#[from] zip::result::ZipError),
}

impl From<AppError> for AppErrorResponse {
Expand Down Expand Up @@ -256,6 +261,8 @@ impl From<AppError> for AppErrorResponse {
ErrorCode::PatcherRunning,
"Stop the patcher before modifying mods",
),

AppError::ZipError(e) => AppErrorResponse::new(ErrorCode::Zip, e.to_string()),
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ fn main() {
commands::get_mod_thumbnail,
commands::get_storage_directory,
commands::reorder_mods,
// Migration
commands::scan_cslol_mods,
commands::import_cslol_mods,
// Patcher
commands::start_patcher,
commands::stop_patcher,
Expand Down
171 changes: 171 additions & 0 deletions src-tauri/src/mods/migration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
use crate::error::{AppError, AppResult};
use crate::state::Settings;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{Read, Write};
use std::path::Path;

use super::{BulkInstallResult, ModLibrary};

/// Metadata for a discovered cslol-manager mod, shown in the UI selection step.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CslolModInfo {
pub folder_name: String,
pub name: String,
pub author: String,
pub version: String,
pub description: String,
}

/// Scan a cslol-manager directory for installed mods.
///
/// Expects `dir` to contain an `installed/` subdirectory where each child folder
/// has the standard fantome layout (`META/info.json`, `WAD/`, etc.).
pub fn scan_cslol_directory(dir: &Path) -> AppResult<Vec<CslolModInfo>> {
let installed_dir = dir.join("installed");
if !installed_dir.is_dir() {
return Err(AppError::InvalidPath(format!(
"No 'installed' directory found in {}",
dir.display()
)));
}

let mut mods = Vec::new();

for entry in fs::read_dir(&installed_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}

let folder_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};

let info_path = path.join("META").join("info.json");
if !info_path.exists() {
tracing::warn!(
"Skipping cslol mod '{}': missing META/info.json",
folder_name
);
continue;
}

match read_cslol_info(&info_path) {
Ok(info) => {
mods.push(CslolModInfo {
folder_name,
name: info.name,
author: info.author,
version: info.version,
description: info.description,
});
}
Err(e) => {
tracing::warn!("Skipping cslol mod '{}': {}", folder_name, e);
}
}
}

mods.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(mods)
}

/// Import selected cslol-manager mods by creating temporary `.fantome` ZIPs
/// and piping them through the existing install pipeline.
pub fn import_cslol_mods(
library: &ModLibrary,
settings: &Settings,
cslol_dir: &Path,
folders: &[String],
) -> AppResult<BulkInstallResult> {
let installed_dir = cslol_dir.join("installed");
let temp_dir = std::env::temp_dir().join("ltk-migration");
fs::create_dir_all(&temp_dir)?;

let mut temp_paths: Vec<String> = Vec::new();

for folder in folders {
let mod_dir = installed_dir.join(folder);
if !mod_dir.is_dir() {
tracing::warn!("Skipping missing folder: {}", folder);
continue;
}

let output_path = temp_dir.join(format!("{}.fantome", folder));
match create_fantome_zip(&mod_dir, &output_path) {
Ok(()) => {
temp_paths.push(output_path.display().to_string());
}
Err(e) => {
tracing::warn!("Failed to create fantome zip for '{}': {}", folder, e);
}
}
}

let result = library.install_mods_from_packages(settings, &temp_paths);

// Clean up temp files
for path in &temp_paths {
let _ = fs::remove_file(path);
}
let _ = fs::remove_dir(&temp_dir);

result
}

/// Create a `.fantome` ZIP archive from a cslol mod directory.
fn create_fantome_zip(mod_dir: &Path, output_path: &Path) -> AppResult<()> {
let file = fs::File::create(output_path)?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);

add_dir_to_zip(&mut zip, mod_dir, mod_dir, options)?;
zip.finish().map_err(AppError::ZipError)?;

Ok(())
}

/// Recursively add directory contents to a ZIP archive.
fn add_dir_to_zip<W: Write + std::io::Seek>(
zip: &mut zip::ZipWriter<W>,
base_dir: &Path,
current_dir: &Path,
options: zip::write::SimpleFileOptions,
) -> AppResult<()> {
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();

let relative = path
.strip_prefix(base_dir)
.map_err(|e| AppError::Other(e.to_string()))?;
let name = relative.to_string_lossy().replace('\\', "/");

if path.is_dir() {
zip.add_directory(format!("{}/", name), options)
.map_err(AppError::ZipError)?;
add_dir_to_zip(zip, base_dir, &path, options)?;
} else {
zip.start_file(&name, options).map_err(AppError::ZipError)?;
let mut f = fs::File::open(&path)?;
let mut buffer = Vec::new();
f.read_to_end(&mut buffer)?;
zip.write_all(&buffer)?;
}
}

Ok(())
}

/// Read and parse a cslol-manager `META/info.json` file.
fn read_cslol_info(path: &Path) -> AppResult<ltk_fantome::FantomeInfo> {
let content = fs::read_to_string(path)?;
let content = content.trim_start_matches('\u{feff}').trim();

serde_json::from_str(content).map_err(AppError::Serialization)
}
3 changes: 3 additions & 0 deletions src-tauri/src/mods/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
mod inspect;
mod library;
mod migration;
mod profiles;

pub use migration::*;

pub use inspect::{inspect_modpkg_file, ModpkgInfo};

use crate::error::{AppError, AppResult};
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,7 @@ pub struct Settings {
/// Whether to patch TFT game files (Map22.wad.client). Default: false.
#[serde(default)]
pub patch_tft: bool,
/// Whether the user has dismissed the cslol-manager migration banner.
#[serde(default)]
pub migration_dismissed: bool,
}
87 changes: 87 additions & 0 deletions src/components/AlertBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { type ReactNode } from "react";
import { LuCircleAlert, LuCircleCheck, LuCircleX, LuInfo, LuX } from "react-icons/lu";
import { twMerge } from "tailwind-merge";

export type AlertBoxVariant = "info" | "success" | "warning" | "error";

export interface AlertBoxProps {
variant?: AlertBoxVariant;
title?: ReactNode;
children?: ReactNode;
icon?: ReactNode;
actions?: ReactNode;
onDismiss?: () => void;
className?: string;
}

const variantStyles: Record<AlertBoxVariant, { border: string; bg: string; icon: string }> = {
info: {
border: "border-blue-800/50",
bg: "bg-blue-950/30",
icon: "text-blue-400",
},
success: {
border: "border-green-800/50",
bg: "bg-green-950/30",
icon: "text-green-400",
},
warning: {
border: "border-amber-800/50",
bg: "bg-amber-950/30",
icon: "text-amber-400",
},
error: {
border: "border-red-800/50",
bg: "bg-red-950/30",
icon: "text-red-400",
},
};

const defaultIcons: Record<AlertBoxVariant, ReactNode> = {
info: <LuInfo className="h-5 w-5" />,
success: <LuCircleCheck className="h-5 w-5" />,
warning: <LuCircleAlert className="h-5 w-5" />,
error: <LuCircleX className="h-5 w-5" />,
};

export function AlertBox({
variant = "info",
title,
children,
icon,
actions,
onDismiss,
className,
}: AlertBoxProps) {
const styles = variantStyles[variant];
const resolvedIcon = icon ?? defaultIcons[variant];

return (
<div
role="alert"
className={twMerge(
"flex items-center gap-4 rounded-lg border px-4 py-3",
styles.border,
styles.bg,
className,
)}
>
<div className={twMerge("shrink-0", styles.icon)}>{resolvedIcon}</div>
<div className="min-w-0 flex-1">
{title && <p className="text-sm font-medium text-surface-100">{title}</p>}
{children && <div className="text-sm text-surface-400">{children}</div>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="shrink-0 rounded-md p-1 text-surface-400 transition-colors hover:bg-surface-700 hover:text-surface-200"
aria-label="Dismiss"
>
<LuX className="h-4 w-4" />
</button>
)}
</div>
);
}
Loading