From 338d545d673f83466cf9f26cc73391bf4c3cb01f Mon Sep 17 00:00:00 2001 From: Crauzer <0xcrauzer@proton.me> Date: Fri, 24 Apr 2026 10:53:04 +0200 Subject: [PATCH] feat: check for storage medium --- Cargo.lock | 1 + src-tauri/Cargo.toml | 9 ++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/storage.rs | 13 ++ src-tauri/src/main.rs | 3 + src-tauri/src/mods/mod.rs | 19 +-- src-tauri/src/state.rs | 7 + src-tauri/src/storage.rs | 218 ++++++++++++++++++++++++++++++ src/hooks/index.ts | 1 + src/hooks/useAutoStartPatcher.ts | 14 +- src/hooks/useHddWarning.ts | 55 ++++++++ src/lib/bindings/Settings.ts | 6 + src/lib/bindings/StorageMedium.ts | 3 + src/lib/bindings/index.ts | 1 + src/lib/tauri.ts | 5 + src/pages/Library.tsx | 5 +- src/test/fixtures.ts | 1 + 17 files changed, 343 insertions(+), 20 deletions(-) create mode 100644 src-tauri/src/commands/storage.rs create mode 100644 src-tauri/src/storage.rs create mode 100644 src/hooks/useHddWarning.ts create mode 100644 src/lib/bindings/StorageMedium.ts diff --git a/Cargo.lock b/Cargo.lock index d889e0c..7bdfc8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2853,6 +2853,7 @@ dependencies = [ "uuid", "walkdir", "webp", + "windows-sys 0.59.0", "xxhash-rust", "zip 2.4.2", "zstd", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9140474..836401e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -72,6 +72,15 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Ioctl", + "Win32_System_IO", +] } + [dev-dependencies] tempfile = "3" assert_matches = "1.5" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index eb2ab38..4eb61b6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -27,6 +27,7 @@ mod platform; mod profiles; mod settings; mod shell; +mod storage; mod workshop; pub use app::*; @@ -40,4 +41,5 @@ pub use platform::*; pub use profiles::*; pub use settings::*; pub use shell::*; +pub use storage::*; pub use workshop::*; diff --git a/src-tauri/src/commands/storage.rs b/src-tauri/src/commands/storage.rs new file mode 100644 index 0000000..7865590 --- /dev/null +++ b/src-tauri/src/commands/storage.rs @@ -0,0 +1,13 @@ +use crate::error::IpcResult; +use crate::storage::{detect_path_storage_medium, StorageMedium}; + +/// Detect whether the given path is on an SSD, HDD, or unknown medium. +/// +/// Returns `Unknown` on non-Windows platforms and for any path we can't +/// resolve to a local volume (e.g. UNC paths, missing drives, permission +/// errors). Callers should treat `Unknown` as "don't warn" rather than +/// blocking the UI. +#[tauri::command] +pub fn detect_storage_medium(path: String) -> IpcResult { + IpcResult::ok(detect_path_storage_medium(&path)) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 044beed..3b8b3d9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -16,6 +16,7 @@ mod overlay; pub mod patcher; mod setup; mod state; +mod storage; mod tray; mod utils; mod workshop; @@ -102,6 +103,8 @@ fn main() { // Shell commands::reveal_in_explorer, commands::minimize_to_tray, + // Storage + commands::detect_storage_medium, // Workshop commands::get_workshop_projects, commands::create_workshop_project, diff --git a/src-tauri/src/mods/mod.rs b/src-tauri/src/mods/mod.rs index f718c7a..2fcf2d1 100644 --- a/src-tauri/src/mods/mod.rs +++ b/src-tauri/src/mods/mod.rs @@ -122,7 +122,7 @@ impl ModLibrary { f(&storage_dir, &index) } - /// Mutate index: acquire lock, load, run closure, save, invalidate overlay. + /// Mutate index: acquire lock, load, run closure, save. /// /// Records the completion timestamp so the file watcher ignores filesystem /// notifications caused by our own writes for [`WATCHER_SUPPRESS_SECS`]. @@ -136,9 +136,6 @@ impl ModLibrary { let mut index = load_library_index(&storage_dir)?; let result = f(&storage_dir, &mut index)?; save_library_index(&storage_dir, &index)?; - if let Err(e) = invalidate_overlay_for_profile(&storage_dir, &index) { - tracing::warn!("Failed to invalidate overlay: {}", e); - } // Drop WAD report cache entries for mods that are no longer in the // library after this mutation (e.g. uninstall paths). if let Some(state) = self @@ -454,20 +451,6 @@ pub(super) fn save_library_index(storage_dir: &Path, index: &LibraryIndex) -> Ap Ok(()) } -/// Delete the active profile's `overlay.json` to force the next build to rebuild. -fn invalidate_overlay_for_profile(storage_dir: &Path, index: &LibraryIndex) -> AppResult<()> { - let active_profile = get_active_profile(index)?; - let overlay_json = storage_dir - .join("profiles") - .join(active_profile.slug.as_str()) - .join("overlay.json"); - if overlay_json.exists() { - std::fs::remove_file(&overlay_json)?; - tracing::info!("Invalidated overlay for profile {}", active_profile.slug); - } - Ok(()) -} - /// Reconcile the library index against the filesystem. /// /// 1. Remove orphaned mod entries (missing files on disk) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 7c27be8..3059a6d 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -238,6 +238,11 @@ pub struct Settings { pub author_profiles: Vec, #[serde(default)] pub default_author_profile_id: Option, + /// Whether the user has dismissed the HDD-performance warning. Once true, + /// we suppress the warning on subsequent patcher starts. Reset by toggling + /// the "show performance warnings" setting if/when we add one. + #[serde(default)] + pub has_seen_hdd_warning: bool, } impl Default for Settings { @@ -268,6 +273,7 @@ impl Default for Settings { wad_blocklist: default_wad_blocklist(), author_profiles: vec![], default_author_profile_id: None, + has_seen_hdd_warning: false, } } } @@ -329,6 +335,7 @@ mod tests { role: Some("3D Artist".to_string()), }], default_author_profile_id: Some("test-id".to_string()), + has_seen_hdd_warning: false, }; let json = serde_json::to_string(&settings).unwrap(); let deserialized: Settings = serde_json::from_str(&json).unwrap(); diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs new file mode 100644 index 0000000..1c0e0fe --- /dev/null +++ b/src-tauri/src/storage.rs @@ -0,0 +1,218 @@ +//! Storage medium detection for performance warnings. +//! +//! On Windows, queries the volume underlying a filesystem path for +//! `IncursSeekPenalty` (HDD vs SSD) via `IOCTL_STORAGE_QUERY_PROPERTY`. +//! This powers a first-time warning when the user's League install or overlay +//! storage lives on a spinning disk — builds on HDD can take 15–20 minutes. + +use serde::Serialize; +use ts_rs::TS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +// `Ssd` and `Hdd` are only constructed inside `windows_impl`; on other targets +// this enum is still emitted for the shared ts-rs binding, so we suppress the +// dead-code lint off-Windows rather than letting CI fail. +#[cfg_attr(not(windows), allow(dead_code))] +pub enum StorageMedium { + Ssd, + Hdd, + Unknown, +} + +/// Detect whether the given path is on an SSD, HDD, or unknown medium. +/// +/// Returns `Unknown` on non-Windows platforms and for any path we can't +/// resolve to a local volume (e.g. UNC paths, missing drives, permission +/// errors). Callers should treat `Unknown` as "don't warn" rather than +/// blocking the UI. +pub fn detect_path_storage_medium(path: &str) -> StorageMedium { + #[cfg(windows)] + { + windows_impl::detect(path) + } + #[cfg(not(windows))] + { + let _ = path; + StorageMedium::Unknown + } +} + +#[cfg(windows)] +mod windows_impl { + use super::StorageMedium; + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use std::ptr; + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + }; + use windows_sys::Win32::System::Ioctl::{ + PropertyStandardQuery, StorageDeviceSeekPenaltyProperty, DEVICE_SEEK_PENALTY_DESCRIPTOR, + IOCTL_STORAGE_QUERY_PROPERTY, STORAGE_PROPERTY_QUERY, + }; + use windows_sys::Win32::System::IO::DeviceIoControl; + + pub fn detect(path: &str) -> StorageMedium { + let Some(device_path) = volume_device_path(path) else { + return StorageMedium::Unknown; + }; + + let wide: Vec = OsStr::new(&device_path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + // SAFETY: CreateFileW with null-terminated wide string. Zero access + // rights suffice for IOCTL_STORAGE_QUERY_PROPERTY (documented). + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE, + ptr::null_mut(), + OPEN_EXISTING, + 0, + ptr::null_mut(), + ) + }; + + if handle == INVALID_HANDLE_VALUE || handle.is_null() { + return StorageMedium::Unknown; + } + + let result = query_seek_penalty(handle); + + // SAFETY: handle came from CreateFileW and is not INVALID_HANDLE_VALUE. + unsafe { + CloseHandle(handle); + } + + result + } + + fn query_seek_penalty(handle: windows_sys::Win32::Foundation::HANDLE) -> StorageMedium { + let query = STORAGE_PROPERTY_QUERY { + PropertyId: StorageDeviceSeekPenaltyProperty, + QueryType: PropertyStandardQuery, + AdditionalParameters: [0; 1], + }; + + let mut descriptor = DEVICE_SEEK_PENALTY_DESCRIPTOR { + Version: 0, + Size: 0, + IncursSeekPenalty: 0, + }; + + let mut bytes_returned: u32 = 0; + + // SAFETY: query and descriptor are stack-allocated; sizes are correct + // for the IOCTL. Handle is valid (checked by caller). + let ok = unsafe { + DeviceIoControl( + handle, + IOCTL_STORAGE_QUERY_PROPERTY, + &query as *const _ as *const _, + std::mem::size_of::() as u32, + &mut descriptor as *mut _ as *mut _, + std::mem::size_of::() as u32, + &mut bytes_returned, + ptr::null_mut(), + ) + }; + + if ok == 0 || bytes_returned == 0 { + return StorageMedium::Unknown; + } + + if descriptor.IncursSeekPenalty != 0 { + StorageMedium::Hdd + } else { + StorageMedium::Ssd + } + } + + /// Resolve an arbitrary filesystem path to the device path of its volume + /// (e.g. `D:\Riot Games\...` → `\\.\D:`). Returns `None` if the path + /// doesn't start with a drive letter we can query. + fn volume_device_path(path: &str) -> Option { + let stripped = path + .strip_prefix(r"\\?\") + .or_else(|| path.strip_prefix("//?/")) + .unwrap_or(path); + + let mut chars = stripped.chars(); + let drive = chars.next()?; + if !drive.is_ascii_alphabetic() { + return None; + } + if chars.next() != Some(':') { + return None; + } + + Some(format!(r"\\.\{}:", drive.to_ascii_uppercase())) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn device_path_from_drive_letter() { + assert_eq!(volume_device_path("C:\\"), Some(r"\\.\C:".to_string())); + assert_eq!( + volume_device_path(r"D:\Riot Games"), + Some(r"\\.\D:".to_string()) + ); + assert_eq!( + volume_device_path("e:/foo/bar"), + Some(r"\\.\E:".to_string()) + ); + } + + #[test] + fn device_path_strips_verbatim_prefix() { + assert_eq!( + volume_device_path(r"\\?\D:\Riot Games"), + Some(r"\\.\D:".to_string()) + ); + } + + #[test] + fn device_path_rejects_non_drive_paths() { + assert_eq!(volume_device_path("/home/user"), None); + assert_eq!(volume_device_path(r"\\server\share"), None); + assert_eq!(volume_device_path(""), None); + assert_eq!(volume_device_path("1:/foo"), None); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_existent_path_returns_unknown_not_error() { + let medium = detect_path_storage_medium("Z:\\definitely-not-a-real-drive\\path"); + assert!(matches!( + medium, + StorageMedium::Unknown | StorageMedium::Ssd | StorageMedium::Hdd + )); + } + + #[cfg(windows)] + #[test] + fn system_drive_reports_ssd_or_hdd_not_unknown() { + // C:\ is expected to exist on any Windows dev or CI machine. We can't + // assert SSD vs HDD specifically, but we can assert the query succeeds. + let medium = detect_path_storage_medium("C:\\"); + assert!( + matches!(medium, StorageMedium::Ssd | StorageMedium::Hdd), + "expected SSD or HDD for C:\\, got {:?}", + medium + ); + } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 1fa26d5..0791c8c 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,5 +1,6 @@ export { useAutoStartPatcher } from "./useAutoStartPatcher"; export { useClickOutside } from "./useClickOutside"; +export { useHddWarning } from "./useHddWarning"; export { usePlatformSupport } from "./usePlatformSupport"; export { usePrevious } from "./usePrevious"; export { useReducedMotion } from "./useReducedMotion"; diff --git a/src/hooks/useAutoStartPatcher.ts b/src/hooks/useAutoStartPatcher.ts index 83560b9..48cfe28 100644 --- a/src/hooks/useAutoStartPatcher.ts +++ b/src/hooks/useAutoStartPatcher.ts @@ -3,16 +3,28 @@ import { useEffect, useRef } from "react"; import { useStartPatcher } from "@/modules/patcher/api"; import { useSettings } from "@/modules/settings"; +import { useHddWarning } from "./useHddWarning"; + export function useAutoStartPatcher() { const { data: settings } = useSettings(); const startPatcher = useStartPatcher(); + const maybeShowHddWarning = useHddWarning(); + const startPatcherRef = useRef(startPatcher); startPatcherRef.current = startPatcher; + + const maybeShowHddWarningRef = useRef(maybeShowHddWarning); + maybeShowHddWarningRef.current = maybeShowHddWarning; + const hasStarted = useRef(false); useEffect(() => { if (hasStarted.current || !settings?.alwaysStartPatcher) return; hasStarted.current = true; - startPatcherRef.current.mutate({}); + + (async () => { + await maybeShowHddWarningRef.current(); + startPatcherRef.current.mutate({}); + })(); }, [settings?.alwaysStartPatcher]); } diff --git a/src/hooks/useHddWarning.ts b/src/hooks/useHddWarning.ts new file mode 100644 index 0000000..14cc466 --- /dev/null +++ b/src/hooks/useHddWarning.ts @@ -0,0 +1,55 @@ +import { useCallback } from "react"; + +import { useToast } from "@/components"; +import { api, isOk } from "@/lib/tauri"; +import { useSaveSettings, useSettings } from "@/modules/settings"; + +/** + * Returns a callback that, when invoked, checks whether the user's League + * install or mod storage lives on a spinning HDD and — if so, and the user + * hasn't already been warned — shows a one-time warning toast. + * + * The flag is only persisted when the warning is actually displayed, so + * users who configure League later still see it on their first real build. + * Silent no-op on non-Windows platforms (detection returns `"unknown"`). + */ +export function useHddWarning() { + const toast = useToast(); + const { data: settings } = useSettings(); + const saveSettings = useSaveSettings(); + + return useCallback(async () => { + if (!settings || settings.hasSeenHddWarning) return; + + const pathsToCheck: string[] = []; + if (settings.leaguePath) pathsToCheck.push(settings.leaguePath); + + const storageDirResult = await api.getStorageDirectory(); + if (isOk(storageDirResult) && storageDirResult.value) { + pathsToCheck.push(storageDirResult.value); + } + + if (pathsToCheck.length === 0) return; + + let isOnHdd = false; + for (const path of pathsToCheck) { + const result = await api.detectStorageMedium(path); + if (isOk(result) && result.value === "hdd") { + isOnHdd = true; + break; + } + } + + if (!isOnHdd) return; + + toast.toast({ + title: "League is on an HDD", + description: + "First mod build may take 15–20 minutes. Later builds only repatch what changed. For best performance, move League to an SSD.", + type: "warning", + timeout: 15000, + }); + + saveSettings.mutate({ ...settings, hasSeenHddWarning: true }); + }, [settings, toast, saveSettings]); +} diff --git a/src/lib/bindings/Settings.ts b/src/lib/bindings/Settings.ts index 9d646e1..f8a511e 100644 --- a/src/lib/bindings/Settings.ts +++ b/src/lib/bindings/Settings.ts @@ -90,4 +90,10 @@ export type Settings = { wadBlocklist: Array; authorProfiles: Array; defaultAuthorProfileId: string | null; + /** + * Whether the user has dismissed the HDD-performance warning. Once true, + * we suppress the warning on subsequent patcher starts. Reset by toggling + * the "show performance warnings" setting if/when we add one. + */ + hasSeenHddWarning: boolean; }; diff --git a/src/lib/bindings/StorageMedium.ts b/src/lib/bindings/StorageMedium.ts new file mode 100644 index 0000000..d86a899 --- /dev/null +++ b/src/lib/bindings/StorageMedium.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type StorageMedium = "ssd" | "hdd" | "unknown"; diff --git a/src/lib/bindings/index.ts b/src/lib/bindings/index.ts index 09bcc5d..0f6525c 100644 --- a/src/lib/bindings/index.ts +++ b/src/lib/bindings/index.ts @@ -40,6 +40,7 @@ export type { Profile } from "./Profile"; export type { ProfileSlug } from "./ProfileSlug"; export type { SaveProjectConfigArgs } from "./SaveProjectConfigArgs"; export type { Settings } from "./Settings"; +export type { StorageMedium } from "./StorageMedium"; export type { Theme } from "./Theme"; export type { ValidationResult } from "./ValidationResult"; export type { WadBlocklistEntry } from "./WadBlocklistEntry"; diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index df341e2..392ab25 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -23,6 +23,7 @@ import type { Profile, SaveProjectConfigArgs, Settings, + StorageMedium, ValidationResult, WorkshopLayerInfo, WorkshopProject, @@ -165,6 +166,10 @@ export const api = { revealInExplorer: (path: string) => invokeResult("reveal_in_explorer", { path }), minimizeToTray: () => invokeResult("minimize_to_tray"), + // Storage + detectStorageMedium: (path: string) => + invokeResult("detect_storage_medium", { path }), + // Workshop getWorkshopProjects: () => invokeResult("get_workshop_projects"), createWorkshopProject: (args: CreateProjectArgs) => diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx index e38669d..eaf8903 100644 --- a/src/pages/Library.tsx +++ b/src/pages/Library.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useToast } from "@/components"; -import { usePlatformSupport } from "@/hooks"; +import { useHddWarning, usePlatformSupport } from "@/hooks"; import { api } from "@/lib/tauri"; import { checkModForSkinhack, @@ -47,6 +47,7 @@ export function Library({ folderId }: LibraryProps = {}) { const { data: patcherStatus } = usePatcherStatus(); const startPatcher = useStartPatcher(); const stopPatcher = useStopPatcher(); + const maybeShowHddWarning = useHddWarning(); const isStarting = patcherStatus?.phase === "building"; const isPatcherActive = patcherStatus?.running ?? false; @@ -88,6 +89,8 @@ export function Library({ folderId }: LibraryProps = {}) { return; } + await maybeShowHddWarning(); + startPatcher.mutate( {}, { diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 278495b..cb0da17 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -27,6 +27,7 @@ export function createMockSettings(overrides?: Partial): Settings { autoRun: false, startInTrayUnlessUpdate: false, alwaysStartPatcher: false, + hasSeenHddWarning: false, ...overrides, }; }