Skip to content
Open
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
60 changes: 60 additions & 0 deletions src-tauri/src/launcher_config/helpers/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use sjmcl_types::partial::{PartialAccess, PartialUpdate};
use std::fs;
use std::path::{MAIN_SEPARATOR, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::path::BaseDirectory;
use tauri::{AppHandle, Manager};

Expand Down Expand Up @@ -247,3 +248,62 @@ pub fn check_exe_path_availability(app: &AppHandle) -> bool {
|| exe_str.contains("/.Trash/"))
}
}

pub async fn auto_clear_download_cache(app: &AppHandle) -> SJMCLResult<()> {
let (cache_dir, retention_hours) = {
let config = app.state::<Mutex<LauncherConfig>>();
let config = config.lock().unwrap();
(
config.download.cache.directory.clone(),
config.download.cache.retention_hours,
)
};

if retention_hours == 0 {
return Ok(());
}

let cutoff = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.saturating_sub(retention_hours as u64 * 3600);

let mut stale_paths = Vec::new();
let mut entries = match tokio::fs::read_dir(&cache_dir).await {
Ok(entries) => entries,
Err(_) => return Ok(()),
};
while let Some(entry) = entries.next_entry().await? {
let file_type = match entry.file_type().await {
Comment on lines +273 to +278

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): Reuse metadata from DirEntry to avoid extra syscalls

Within the loop, tokio::fs::metadata(&path) is called for every entry even though DirEntry can already provide metadata. For large caches this roughly doubles metadata syscalls. Prefer entry.metadata().await and derive both file_type and modified from that single result to cut IO and simplify error handling.

Ok(ft) => ft,
Err(_) => continue,
};
if !file_type.is_file() {
continue;
}
let path = entry.path();
let modified = tokio::fs::metadata(&path)
.await
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs());
if modified.is_some_and(|secs| secs < cutoff) {
stale_paths.push(path);
}
}

futures::future::join_all(stale_paths.into_iter().map(|path| async move {
if let Err(e) = tokio::fs::remove_file(&path).await {
log::warn!(
"Failed to remove stale download cache entry {:?}: {}",
path,
e
)
}
}))
.await;

Ok(())
}
4 changes: 4 additions & 0 deletions src-tauri/src/launcher_config/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ structstruck::strike! {
},
pub cache: struct {
pub directory: PathBuf,
#[default = true]
pub auto_clear: bool,
#[default = 24]
pub retention_hours: u32,
},
pub proxy: ProxyConfig,
},
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ pub async fn run() {
let os = launcher_config.basic_info.platform.clone();
let exe_sha256 = launcher_config.basic_info.exe_sha256.clone();
let auto_purge_launcher_logs = launcher_config.general.advanced.auto_purge_launcher_logs;
let auto_clear_download_cache = launcher_config.download.cache.auto_clear;
let launcher_mcp_config = launcher_config.intelligence.mcp_server.launcher.clone();
app.manage(Mutex::new(launcher_config));

Expand Down Expand Up @@ -317,6 +318,14 @@ pub async fn run() {
});
}

// Auto clear stale download cache entries if enabled
if auto_clear_download_cache {
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let _ = launcher_config::helpers::misc::auto_clear_download_cache(&app_handle).await;
});
}

// On platforms other than macOS, set the menu to empty to hide the default menu.
// On macOS, some shortcuts depend on default menu: https://github.com/tauri-apps/tauri/issues/12458
#[cfg(not(target_os = "macos"))]
Expand Down
8 changes: 8 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,14 @@
"title": "Clear Download Cache",
"description": "Clear all files in the cache directory, which will not affect downloaded resource files.",
"button": "Clear"
},
"autoClear": {
"title": "Auto Clear Cache",
"description": "Automatically delete download cache files older than the retention threshold on startup"
},
"retention": {
"title": "Retention Threshold",
"description": "Files newer than the threshold will not be cleared"
}
}
},
Expand Down
8 changes: 8 additions & 0 deletions src/locales/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,14 @@
"title": "清除下载缓存",
"description": "清除下载缓存目录中的所有文件,此操作不会清除已下载的资源文件",
"button": "清除"
},
"autoClear": {
"title": "自动清除缓存",
"description": "启动时自动删除超过保留时间的下载缓存文件"
},
"retention": {
"title": "保留时间阈值",
"description": "时间在阈值内的文件不会被清除"
}
}
},
Expand Down
4 changes: 4 additions & 0 deletions src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ export interface LauncherConfig {
};
cache: {
directory: string;
autoClear: boolean;
retentionHours: number;
};
proxy: {
enabled: boolean;
Expand Down Expand Up @@ -319,6 +321,8 @@ export const defaultConfig: LauncherConfig = {
},
cache: {
directory: "/mock/path/to/cache/",
autoClear: true,
retentionHours: 24,
},
proxy: {
enabled: false,
Expand Down
79 changes: 79 additions & 0 deletions src/pages/settings/download/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ const DownloadSettingsPage = () => {
const [speedLimitValue, setSpeedLimitValue] = useState<number>(
downloadConfigs.transmission.speedLimitValue
);
const [retentionHours, setRetentionHours] = useState<number>(
downloadConfigs.cache.retentionHours
);
const [sliderRetentionHours, setSliderRetentionHours] = useState<number>(
downloadConfigs.cache.retentionHours
);
const [isClearingDownloadCache, setIsClearingDownloadCache] =
useState<boolean>(false);

Expand Down Expand Up @@ -354,6 +360,79 @@ const DownloadSettingsPage = () => {
</Button>
),
},
{
title: t("DownloadSettingPage.cache.settings.autoClear.title"),
description: t(
"DownloadSettingPage.cache.settings.autoClear.description"
),
children: (
<Switch
colorScheme={primaryColor}
isChecked={downloadConfigs.cache.autoClear}
onChange={(event) => {
update("download.cache.autoClear", event.target.checked);
}}
/>
),
},
...(downloadConfigs.cache.autoClear
? [
{
title: t("DownloadSettingPage.cache.settings.retention.title"),
description: t(
"DownloadSettingPage.cache.settings.retention.description"
),
children: (
<HStack spacing={4}>
<Slider
min={1}
max={168}
step={1}
w={32}
colorScheme={primaryColor}
value={sliderRetentionHours}
onChange={(value) => {
setSliderRetentionHours(value);
setRetentionHours(value);
Comment on lines +387 to +396

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Keep slider value within min/max bounds when syncing from state

sliderRetentionHours is set directly from retentionHours, but the slider only supports values in [1, 168]. If retentionHours ever goes outside this range (e.g., via existing configs or future changes), the slider could receive an invalid value and display incorrectly. Please clamp retentionHours to the slider’s min/max both when assigning sliderRetentionHours and when calling update.

Suggested implementation:

                    <Slider
                      min={1}
                      max={168}
                      step={1}
                      w={32}
                      colorScheme={primaryColor}
                      value={Math.min(168, Math.max(1, sliderRetentionHours))}
                      onChange={(value) => {
                        setSliderRetentionHours(value);
                        setRetentionHours(value);
                      }}
                      onBlur={() => {
                        const clampedRetentionHours = Math.min(
                          168,
                          Math.max(1, retentionHours)
                        );
                        update("download.cache.retentionHours", clampedRetentionHours);
                      }}

To fully implement your comment, also ensure that any place where sliderRetentionHours is initialized or synchronized from retentionHours clamps the value, for example:

  • If you have something like setSliderRetentionHours(retentionHours); (e.g., in a useEffect or initialization), change it to:
setSliderRetentionHours(Math.min(168, Math.max(1, retentionHours)));

This guarantees that both the state driving the slider and the value persisted via update always stay within the slider’s [1, 168] bounds.

}}
onBlur={() => {
update("download.cache.retentionHours", retentionHours);
}}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
<NumberInput
min={1}
max={168}
size="xs"
maxW={16}
focusBorderColor={`${primaryColor}.500`}
value={retentionHours}
onChange={(value) => {
if (!/^\d*$/.test(value)) return;
setRetentionHours(Number(value));
}}
onBlur={() => {
setSliderRetentionHours(retentionHours);
update(
"download.cache.retentionHours",
Math.max(1, Math.min(retentionHours, 168))
);
}}
>
<NumberInputField />
</NumberInput>
<Text fontSize="xs" className="secondary-text">
h
</Text>
</HStack>
),
},
]
: []),
],
},
{
Expand Down
Loading