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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ mod platform;
mod profiles;
mod settings;
mod shell;
mod storage;
mod workshop;

pub use app::*;
Expand All @@ -40,4 +41,5 @@ pub use platform::*;
pub use profiles::*;
pub use settings::*;
pub use shell::*;
pub use storage::*;
pub use workshop::*;
13 changes: 13 additions & 0 deletions src-tauri/src/commands/storage.rs
Original file line number Diff line number Diff line change
@@ -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<StorageMedium> {
IpcResult::ok(detect_path_storage_medium(&path))
}
3 changes: 3 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod overlay;
pub mod patcher;
mod setup;
mod state;
mod storage;
mod tray;
mod utils;
mod workshop;
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 1 addition & 18 deletions src-tauri/src/mods/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`].
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ pub struct Settings {
pub author_profiles: Vec<AuthorProfile>,
#[serde(default)]
pub default_author_profile_id: Option<String>,
/// 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 {
Expand Down Expand Up @@ -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,
}
}
}
Expand Down Expand Up @@ -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();
Expand Down
218 changes: 218 additions & 0 deletions src-tauri/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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<u16> = 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::<STORAGE_PROPERTY_QUERY>() as u32,
&mut descriptor as *mut _ as *mut _,
std::mem::size_of::<DEVICE_SEEK_PENALTY_DESCRIPTOR>() 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<String> {
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
);
}
}
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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";
14 changes: 13 additions & 1 deletion src/hooks/useAutoStartPatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
Loading
Loading