diff --git a/crates/ide-host/src/data_dirs.rs b/crates/ide-host/src/data_dirs.rs index b76b168cc..531e47d13 100644 --- a/crates/ide-host/src/data_dirs.rs +++ b/crates/ide-host/src/data_dirs.rs @@ -1,9 +1,39 @@ //! Storage policy and best-effort migration of rebuildable desktop data. +use std::ffi::OsString; use std::fs; use std::io; use std::path::{Path, PathBuf}; +/// Moves the rebuildable local data (tools, language servers, indexes, caches); +/// configuration and roaming data keep following the OS. +pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR"; + +/// `$OPENIDEA_LOCAL_DATA_DIR/` when set, else the OS local data directory +/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree +/// debug builds apart under one root. A set but unusable path is an error, never a +/// silent fallback to the OS location. +pub fn local_data_dir(identifier: &str) -> Result { + resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier) +} + +fn resolve_local(var: Option, os: Option, identifier: &str) -> Result { + let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else { + return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into()); + }; + // The working directory depends on how the IDE was launched. + if !root.is_absolute() { + return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display())); + } + let dir = root.join(identifier); + let probe = dir.join(".openidea-write-check"); + fs::create_dir_all(&dir) + .and_then(|()| fs::write(&probe, b"")) + .and_then(|()| fs::remove_file(&probe)) + .map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?; + Ok(dir) +} + /// Unknown entries and recovery/user content deliberately stay in roaming data. pub fn is_local(entry: &Path) -> bool { ["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"] @@ -276,6 +306,55 @@ fn relative_wrapper(text: &str, source: &Path) -> String { mod tests { use super::*; + #[test] + fn local_dir_follows_the_os_unless_the_variable_is_set() { + let os = Some(PathBuf::from("/os-local")); + for var in [None, Some(OsString::new())] { + assert_eq!( + resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(), + Path::new("/os-local/ai.l0ng.openidea") + ); + } + assert!(resolve_local(None, None, "id").is_err()); + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("missing/nested"); + let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap(); + assert_eq!(dir, root.join("ai.l0ng.openidea")); + assert!(dir.is_dir()); + assert_eq!(fs::read_dir(&dir).unwrap().count(), 0); + // Debug worktrees have their own identifiers and stay apart under one root. + assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir); + } + + #[test] + fn local_dir_rejects_relative_and_unwritable_paths() { + for relative in ["local", "./local", "../local"] { + let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err(); + assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}"); + } + let tmp = tempfile::tempdir().unwrap(); + let blocker = tmp.path().join("file"); + fs::write(&blocker, "").unwrap(); + let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err(); + assert!(error.contains("cannot write"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn local_dir_rejects_an_existing_read_only_directory() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("id"); + fs::create_dir(&dir).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap(); + // Root ignores permission bits. + if fs::write(dir.join("probe"), "").is_err() { + assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write")); + } + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + } + #[test] fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() { let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"; diff --git a/docs/data-directories.md b/docs/data-directories.md new file mode 100644 index 000000000..56ca14b50 --- /dev/null +++ b/docs/data-directories.md @@ -0,0 +1,24 @@ +# Data directories + +OpenIDEA keeps its rebuildable data, such as downloaded tools, language servers, +JDKs and index caches, in the operating system's local data directory: + +- **Windows:** `%LOCALAPPDATA%\ai.l0ng.openidea` +- **macOS:** `~/Library/Application Support/ai.l0ng.openidea` +- **Linux:** `~/.local/share/ai.l0ng.openidea` + +To keep this data elsewhere, for example on another drive, set +`OPENIDEA_LOCAL_DATA_DIR` to an absolute path before starting OpenIDEA. The IDE +then uses `/ai.l0ng.openidea` and creates it when +missing. If the path is relative or cannot be written, OpenIDEA shows an error +and exits instead of falling back to the default location. + +```bat +setx OPENIDEA_LOCAL_DATA_DIR "E:\OpenIDEA" +``` + +Settings, plugins, scratches and other user data stay in the operating system's +data and configuration directories. Logs stay in their usual location, so they +are still written when the variable is wrong. Existing data is not moved: quit +OpenIDEA and move the old directory's contents to the new location, or let the +IDE download tools and rebuild indexes again. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0b71c43e6..d9215f4a7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -89,11 +89,15 @@ pub fn run() { linux_webkit::configure(); let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier); + let local_data_dir = ide_host::data_dirs::local_data_dir(&context.config().identifier); // Managed LSP/DAP installs; must run before other threads start and before PATH is cached. - if let Some(data) = dirs::data_local_dir() { - ide_tools::extend_process_path(&data.join(&context.config().identifier)); - ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data.join(&context.config().identifier))); - ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data.join(&context.config().identifier))); + match &local_data_dir { + Ok(data) => { + ide_tools::extend_process_path(data); + ide_dap::adapter::set_tools_root(ide_tools::tools_dir(data)); + ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(data)); + } + Err(error) => tracing::error!("{error}"), } if let Some(refresh) = refresh_shell_path { refresh(); @@ -176,7 +180,20 @@ pub fn run() { }) .setup(move |app| { builder.mount_events(app); - let state = AppState::new(app.handle().clone())?; + let local_data_dir = match local_data_dir { + Ok(dir) => dir, + Err(error) => { + use tauri_plugin_dialog::DialogExt; + let handle = app.handle().clone(); + app.dialog() + .message(error) + .title("OpenIDEA") + .kind(tauri_plugin_dialog::MessageDialogKind::Error) + .show(move |_| handle.exit(1)); + return Ok(()); + } + }; + let state = AppState::new(app.handle().clone(), local_data_dir)?; let windows = app.state::(); if let Ok(cwd) = std::env::current_dir() { match windows::cli_launch(&std::env::args().collect::>(), &cwd) { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index b50e7496d..a9de324d5 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -52,7 +52,7 @@ pub struct Startup { } impl AppState { - pub fn new(app: AppHandle) -> Result> { + pub fn new(app: AppHandle, local_data_dir: std::path::PathBuf) -> Result> { ide_workspace::process::mark_start(); let process_to_main = ide_workspace::process::before_main(); let process_to_setup = ide_workspace::process::process_age(); @@ -67,7 +67,6 @@ impl AppState { } let config_dir = app.path().app_config_dir()?; let data_dir = app.path().app_data_dir()?; - let local_data_dir = app.path().app_local_data_dir()?; std::fs::create_dir_all(&config_dir)?; std::fs::create_dir_all(&data_dir)?; if let Err(error) = std::fs::create_dir_all(&local_data_dir) {