diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a0abe86..6510bb1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -21,6 +21,7 @@ "decorum:allow-show-snap-overlay", "fs:default", "dialog:default", + "dialog:allow-open", "notification:default", "clipboard-manager:default", "clipboard-manager:allow-write-text", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 10a6299..3f6fc8d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,7 +6,7 @@ use openssl::sign::Verifier; use sha2::{Digest, Sha512}; use std::fs; use std::path::Path; -use std::sync::OnceLock; +use std::sync::{OnceLock, RwLock}; use std::time::Instant; use std::{ fs::{File, create_dir_all}, @@ -33,6 +33,19 @@ use std::os::unix::fs::PermissionsExt; #[cfg(not(debug_assertions))] use tauri_plugin_updater::UpdaterExt; +struct AppState { + custom_data_dir: RwLock>, +} + +fn data_dir(app: &AppHandle) -> Result { + let state = app.state::(); + let guard = state.custom_data_dir.read().unwrap(); + match &*guard { + Some(p) => Ok(p.clone()), + None => app.path().app_local_data_dir().map_err(|e| e.to_string()), + } +} + static CANCEL_MAP: OnceLock> = OnceLock::new(); fn cancel_map() -> &'static DashMap { @@ -120,7 +133,7 @@ async fn unzip_to_dir(app: AppHandle, zip_path: PathBuf, out_dir: PathBuf, name: #[tauri::command] fn folder_size(app: AppHandle, version: String) -> String { - let path = match app.path().app_local_data_dir() { + let path = match data_dir(&app) { Ok(p) => p.join("game").join(&version), Err(_) => return "-1".to_string(), }; @@ -160,20 +173,15 @@ async fn download( let _ = app.emit("download-start", format!("{}", name)); let window = app.get_webview_window("main").expect("main window missing"); - let downloads_path = match app.path().app_local_data_dir() { - Ok(p) => p.join("downloads"), - Err(_) => { - let _ = &app.emit("download-stop", format!("{}", name)); - return "-1".to_string(); - } - }; - let game_path = match app.path().app_local_data_dir() { - Ok(p) => p.join("game"), + let base_dir = match data_dir(&app) { + Ok(p) => p, Err(_) => { let _ = &app.emit("download-stop", format!("{}", name)); return "-1".to_string(); } }; + let downloads_path = base_dir.join("downloads"); + let game_path = base_dir.join("game"); let download_part_path = downloads_path.join(format!("{}.part", name)); let download_zip_path = downloads_path.join(format!("{}.zip", name)); @@ -414,7 +422,7 @@ fn launch_game( use_wine: bool, wine_command: String, ) { - let game_folder = match app.path().app_local_data_dir() { + let game_folder = match data_dir(&app) { Ok(p) => p.join("game").join(&name), Err(_) => return, }; @@ -473,10 +481,7 @@ fn launch_game( } } - let bepinex_folder = match app.path().app_local_data_dir() { - Ok(p) => p.join("game").join(&name).join("BepInEx"), - Err(_) => return, - }; + let bepinex_folder = game_folder.join("BepInEx"); if platform() == "macos" && !bepinex_folder.exists() && executable.ends_with(".app") { if let Err(_) = Command::new("open") @@ -530,6 +535,125 @@ async fn cancel_download(name: String) { cancel_map().insert(name.clone(), true); } +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +fn move_dir(src: &Path, dst: &Path) -> std::io::Result<()> { + if !src.exists() { + return Ok(()); + } + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + if dst.exists() { + fs::remove_dir_all(dst)?; + } + match fs::rename(src, dst) { + Ok(()) => Ok(()), + Err(_) => { + copy_dir_recursive(src, dst)?; + fs::remove_dir_all(src)?; + Ok(()) + } + } +} + +#[tauri::command] +async fn move_game_data(app: AppHandle, destination: String) -> Result<(), String> { + let default_dir = app.path().app_local_data_dir().map_err(|e| e.to_string())?; + let current_dir = data_dir(&app)?; + + let new_dir = if destination.is_empty() { + default_dir.clone() + } else { + PathBuf::from(&destination) + }; + + if current_dir == new_dir { + return Ok(()); + } + + let src_game = current_dir.join("game"); + let dst_game = new_dir.join("game"); + let src_downloads = current_dir.join("downloads"); + let dst_downloads = new_dir.join("downloads"); + + tauri::async_runtime::spawn_blocking(move || { + fs::create_dir_all(&new_dir).map_err(|e| e.to_string())?; + move_dir(&src_game, &dst_game).map_err(|e| e.to_string())?; + move_dir(&src_downloads, &dst_downloads).map_err(|e| e.to_string())?; + Ok::<(), String>(()) + }) + .await + .map_err(|e| e.to_string())??; + + let state = app.state::(); + let mut guard = state.custom_data_dir.write().unwrap(); + if destination.is_empty() { + *guard = None; + } else { + *guard = Some(PathBuf::from(&destination)); + } + + Ok(()) +} + +#[tauri::command] +fn restart_app(app: AppHandle) { + app.restart(); +} + +#[tauri::command] +fn open_game_folder(app: AppHandle, version: String) -> Result<(), String> { + let dir = data_dir(&app)?; + let path = dir.join("game").join(&version); + if !path.exists() || !path.is_dir() { + return Err(format!("Game folder \"{}\" not found.", path.display())); + } + #[cfg(target_os = "macos")] + Command::new("open") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + #[cfg(target_os = "windows")] + Command::new("explorer") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + #[cfg(target_os = "linux")] + Command::new("xdg-open") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn remove_stale_executable(app: AppHandle, version: String, executable: String) -> bool { + let dir = match data_dir(&app) { + Ok(d) => d, + Err(_) => return false, + }; + let path = dir.join("game").join(&version).join(&executable); + if path.exists() { + fs::remove_file(&path).is_ok() + } else { + false + } +} + #[tauri::command] fn open_new_window( app: tauri::AppHandle, @@ -597,9 +721,32 @@ pub fn run() { folder_size, verify_signature, cancel_download, - open_new_window + open_new_window, + move_game_data, + restart_app, + open_game_folder, + remove_stale_executable ]) .setup(|app| { + let custom_dir = app + .path() + .app_config_dir() + .ok() + .map(|d| d.join("settings.json")) + .filter(|p| p.exists()) + .and_then(|p| fs::read_to_string(p).ok()) + .and_then(|c| serde_json::from_str::(&c).ok()) + .and_then(|v| { + v.get("customDataLocation") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + }); + + app.manage(AppState { + custom_data_dir: RwLock::new(custom_dir), + }); + let window = app.get_webview_window("main").unwrap(); #[cfg(target_os = "windows")] diff --git a/src/app/main/layout.tsx b/src/app/main/layout.tsx index bfb97cd..6be0707 100644 --- a/src/app/main/layout.tsx +++ b/src/app/main/layout.tsx @@ -89,11 +89,13 @@ export default function RootLayout ({ const [linuxWineCommand, setLinuxWineCommand] = useState('wine %path%') const [theme, setTheme] = useState('dark') + const [customDataLocation, setCustomDataLocation] = useState('') const [versionsList, setVersionsList] = useState>({}) const [modsList, setModsList] = useState< Record> >({}) + const [movingData, setMovingData] = useState(false) function getSpecialVersionsList (game?: number): GameVersion[] { if (!serverVersionList) return [] @@ -117,7 +119,7 @@ export default function RootLayout ({ } const closePopup = useCallback(() => { - if (popupMode == 0 && selectedGame && pathname === '/') { + if (popupMode == 0 && selectedGame && pathname === '/main') { setSelectedGame(null) setSelectedVersionList([]) } else if (viewingInfoFromDownloads) { @@ -312,7 +314,8 @@ export default function RootLayout ({ sidebarAlwaysShowGames: true, linuxUseWine: false, linuxWineCommand: 'wine %path%', - theme: 'dark' + theme: 'dark', + customDataLocation: '' } }) const versionsLocal = await load('versions.json', { @@ -623,15 +626,10 @@ export default function RootLayout ({ verInfo.lastRevision > 0 && value / 1000 <= verInfo.lastRevision ) { - if ( - await exists('game/' + key + '/' + verInfo.executable, { - baseDir: BaseDirectory.AppLocalData - }) - ) - await remove('game/' + key + '/' + verInfo.executable, { - baseDir: BaseDirectory.AppLocalData, - recursive: true - }) + await invoke('remove_stale_executable', { + version: key, + executable: verInfo.executable + }) } } })() @@ -688,6 +686,9 @@ export default function RootLayout ({ watchSettings('linuxWineCommand', v => setLinuxWineCommand(v ?? 'wine %path%') ) + watchSettings('customDataLocation', v => + setCustomDataLocation(v ?? '') + ) watchVersions>('list', v => setVersionsList(v ?? {})) watchVersions>>('mods', v => @@ -756,8 +757,11 @@ export default function RootLayout ({ linuxUseWine, linuxWineCommand, theme, + customDataLocation, versionsList, - modsList + modsList, + movingData, + setMovingData }} >
)} + {movingData && ( +
+

Moving data...

+
+ )} )} diff --git a/src/app/main/settings/page.tsx b/src/app/main/settings/page.tsx index deffa2f..61dd447 100644 --- a/src/app/main/settings/page.tsx +++ b/src/app/main/settings/page.tsx @@ -4,6 +4,8 @@ import { Setting } from '@/components/Setting' import { useGlobal } from '@/providers/GlobalProvider' import { copyToClipboard } from '@/lib/clipboard' import { platform } from '@tauri-apps/plugin-os' +import { open } from '@tauri-apps/plugin-dialog' +import { invoke } from '@tauri-apps/api/core' import Dropdown from '@/components/Dropdown' export default function Settings () { @@ -14,7 +16,10 @@ export default function Settings () { sidebarAlwaysShowGames, linuxUseWine, linuxWineCommand, - theme + theme, + customDataLocation, + downloadProgress, + setMovingData } = useGlobal() return ( @@ -58,6 +63,45 @@ export default function Settings () { className='input-field my-1' hidden={!(platform() == 'linux' && linuxUseWine)} > + 0} + onChange={async () => { + if (customDataLocation) { + try { + setMovingData(true) + await invoke('move_game_data', { destination: '' }) + await settings?.set('customDataLocation', '') + await settings?.save() + await invoke('restart_app') + } catch { + setMovingData(false) + } + } else { + const selected = await open({ + directory: true, + title: 'Select data location' + }) + if (!selected || typeof selected !== 'string') return + try { + setMovingData(true) + await invoke('move_game_data', { destination: selected }) + await settings?.set('customDataLocation', selected) + await settings?.save() + await invoke('restart_app') + } catch { + setMovingData(false) + } + } + }} + title='Move game data to a custom folder location. Disabled during downloads.' + /> + {customDataLocation && ( +

+ {customDataLocation} +

+ )}

+

onChange(!value)} /> {value && } diff --git a/src/lib/util.ts b/src/lib/util.ts index 32039b8..9e99eae 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -1,39 +1,15 @@ import { message } from '@tauri-apps/plugin-dialog' -import { BaseDirectory, exists, stat } from '@tauri-apps/plugin-fs' -import { openPath } from '@tauri-apps/plugin-opener' -import { join, appLocalDataDir } from '@tauri-apps/api/path' import { invoke } from '@tauri-apps/api/core' export const openFolder = async (name: string) => { - const relativePath = await join('game', name) - const basePath = await appLocalDataDir() - const absolutePath = await join(basePath, relativePath) - - const folderExists = await exists(relativePath, { - baseDir: BaseDirectory.AppLocalData - }) - - if (!folderExists) { - await message(`Game folder "${absolutePath}" not found.`, { + try { + await invoke('open_game_folder', { version: name }) + } catch (e) { + await message(String(e), { title: 'Folder not found', kind: 'error' }) - return } - - const folderStat = await stat(relativePath, { - baseDir: BaseDirectory.AppLocalData - }) - - if (folderStat.isFile) { - await message(`Game folder "${absolutePath}" not found.`, { - title: 'Folder not found', - kind: 'error' - }) - return - } - - await openPath(absolutePath) } export const formatEtaSmart = (seconds: number) => { diff --git a/src/providers/GlobalProvider.tsx b/src/providers/GlobalProvider.tsx index dbab03a..5818e1b 100644 --- a/src/providers/GlobalProvider.tsx +++ b/src/providers/GlobalProvider.tsx @@ -57,8 +57,11 @@ type GlobalCtxType = { linuxUseWine: boolean linuxWineCommand: string theme: string + customDataLocation: string versionsList: Record modsList: Record> + movingData: boolean + setMovingData: Dispatch> } const GlobalCtx = createContext(null) diff --git a/src/types/SettingProps.ts b/src/types/SettingProps.ts index adcaecc..7df7771 100644 --- a/src/types/SettingProps.ts +++ b/src/types/SettingProps.ts @@ -4,4 +4,5 @@ export type SettingProps = { onChange: (val: boolean) => void className?: string title?: string + disabled?: boolean }