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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,4 @@ Platform-specific dependencies: SSH and FTP crates use different TLS backends on

- Always run `cargo +nightly fmt --all` and `cargo clippy --no-default-features -- -Dwarnings` after modifying Rust code
- Always put plans to `./.claude/plans/`
- When changing behavior that is documented under `docs/` (paths, config keys, commands, flags, etc.), update BOTH `docs/en-US/` and `docs/zh-CN/` to keep the translations in sync
4 changes: 2 additions & 2 deletions docs/en-US/configuration/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ To enter the configuration, press `<CTRL+C>` from the termscp home.
termscp requires these paths to be accessible:

- `$HOME/.config/termscp/` on Linux/BSD
- `$HOME/Library/Application Support/termscp` on macOS
- `FOLDERID_RoamingAppData\termscp\` on Windows
- `$HOME/.config/termscp/` on macOS
- `%USERPROFILE%\.termscp\` on Windows

## Parameters

Expand Down
8 changes: 4 additions & 4 deletions docs/en-US/configuration/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ are two quick fixes:
```

2. Edit your theme by hand. If you use a custom theme, edit the file and add the
missing key. The theme is located at `$CONFIG_DIR/termscp/theme.toml`, where
missing key. The theme is located at `$CONFIG_DIR/theme.toml`, where
`$CONFIG_DIR` is:

- FreeBSD/Linux: `$HOME/.config/`
- macOS: `$HOME/Library/Application Support`
- Windows: `%appdata%`
- FreeBSD/Linux: `$HOME/.config/termscp`
- macOS: `$HOME/.config/termscp`
- Windows: `%USERPROFILE%\.termscp`

Missing keys are reported in the CHANGELOG under `BREAKING CHANGES` for the
version you have just installed.
Expand Down
4 changes: 2 additions & 2 deletions docs/en-US/usage/bookmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ termscp lets you save your favourite hosts as bookmarks, which can then be loade
Bookmarks are saved, when possible, in the configuration directory:

- `$HOME/.config/termscp/` on Linux/BSD
- `$HOME/Library/Application Support/termscp` on macOS
- `FOLDERID_RoamingAppData\termscp\` on Windows
- `$HOME/.config/termscp/` on macOS
- `%USERPROFILE%\.termscp\` on Windows

## Saving passwords

Expand Down
4 changes: 2 additions & 2 deletions docs/zh-CN/configuration/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ termscp 支持大量用户自定义参数。termscp 将这些参数存储在一
termscp 要求以下路径可访问:

- Linux/BSD 上的 `$HOME/.config/termscp/`
- macOS 上的 `$HOME/Library/Application Support/termscp`
- Windows 上的 `FOLDERID_RoamingAppData\termscp\`
- macOS 上的 `$HOME/.config/termscp/`
- Windows 上的 `%USERPROFILE%\.termscp\`

## 参数

Expand Down
8 changes: 4 additions & 4 deletions docs/zh-CN/configuration/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ termscp 接受以下颜色格式:
termscp theme <theme.toml>
```

2. 手动编辑你的主题。如果你使用自定义主题,请编辑该文件并添加缺失的键。主题位于 `$CONFIG_DIR/termscp/theme.toml`,其中 `$CONFIG_DIR` 为:
2. 手动编辑你的主题。如果你使用自定义主题,请编辑该文件并添加缺失的键。主题位于 `$CONFIG_DIR/theme.toml`,其中 `$CONFIG_DIR` 为:

- FreeBSD/Linux:`$HOME/.config/`
- macOS:`$HOME/Library/Application Support`
- Windows:`%appdata%`
- FreeBSD/Linux:`$HOME/.config/termscp`
- macOS:`$HOME/.config/termscp`
- Windows:`%USERPROFILE%\.termscp`

缺失的键会在你刚安装的版本的 CHANGELOG 中 `BREAKING CHANGES` 部分列出。

Expand Down
4 changes: 2 additions & 2 deletions docs/zh-CN/usage/bookmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ termscp 允许你将常用的主机保存为书签,随后可从主界面快速
书签会在可能的情况下保存在配置目录中:

- Linux/BSD 上为 `$HOME/.config/termscp/`
- macOS 上为 `$HOME/Library/Application Support/termscp`
- Windows 上为 `FOLDERID_RoamingAppData\termscp\`
- macOS 上为 `$HOME/.config/termscp/`
- Windows 上为 `%USERPROFILE%\.termscp\`

## 保存密码

Expand Down
176 changes: 154 additions & 22 deletions src/system/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,89 @@
use std::path::{Path, PathBuf};
use std::sync::LazyLock;

/// termscp's configuration directory, including the `termscp` project subdirectory.
///
/// See [`config_dir`] for the per-platform locations.
#[cfg(not(test))]
static CONF_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::config_dir);
static CONF_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(config_dir);
#[cfg(test)]
static CONF_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(|| Some(std::env::temp_dir()));
static CONF_DIR: LazyLock<Option<PathBuf>> =
LazyLock::new(|| Some(std::env::temp_dir().join("termscp")));

/// termscp's cache directory, including the `termscp` project subdirectory.
///
/// The cache directory stays at the platform-native location (`dirs::cache_dir`)
/// on every platform.
#[cfg(not(test))]
static CACHE_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::cache_dir);
static CACHE_DIR: LazyLock<Option<PathBuf>> =
LazyLock::new(|| dirs::cache_dir().map(|dir| dir.join("termscp")));
#[cfg(test)]
static CACHE_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(|| Some(std::env::temp_dir()));
static CACHE_DIR: LazyLock<Option<PathBuf>> =
LazyLock::new(|| Some(std::env::temp_dir().join("termscp")));

/// Resolves termscp's configuration directory for the current platform,
/// including the `termscp` project subdirectory.
///
/// The location is platform-specific:
///
/// - **Linux** (and other Unix): `$XDG_CONFIG_HOME/termscp` (usually `~/.config/termscp`)
/// - **macOS**: `~/.config/termscp` (instead of `~/Library/Application Support/termscp`)
/// - **Windows**: `%USERPROFILE%\.termscp` (instead of the roaming `%APPDATA%\termscp`)
#[cfg(not(test))]
fn config_dir() -> Option<PathBuf> {
#[cfg(macos)]
{
// macOS: use ~/.config/termscp for consistency with Linux and easier
// CLI access, instead of ~/Library/Application Support/termscp.
dirs::home_dir().map(|home| home.join(".config").join("termscp"))
}
#[cfg(win)]
{
// Windows: use %USERPROFILE%\.termscp instead of the roaming %APPDATA%.
dirs::home_dir().map(|home| home.join(".termscp"))
}
#[cfg(not(any(macos, win)))]
{
// Linux and other platforms: keep the XDG-compliant location.
dirs::config_dir().map(|dir| dir.join("termscp"))
}
}

/// Returns the legacy (pre-1.1.0) configuration directory, if the current
/// platform used a different location than the one returned by [`config_dir`].
///
/// Used to migrate an existing user configuration to the new location.
/// Returns `None` on platforms where the location did not change (e.g. Linux).
#[cfg(not(test))]
fn legacy_config_dir() -> Option<PathBuf> {
#[cfg(any(macos, win))]
{
// Before 1.1.0 the config dir was always `dirs::config_dir()/termscp`.
dirs::config_dir().map(|dir| dir.join("termscp"))
}
#[cfg(not(any(macos, win)))]
{
None
}
}

/// In tests we never want to touch the developer's real configuration, so there
/// is no legacy directory to migrate from.
#[cfg(test)]
fn legacy_config_dir() -> Option<PathBuf> {
None
}

/// Get termscp config directory path and initialize it.
/// Returns None if it's not possible to initialize it
pub fn init_config_dir() -> Result<Option<PathBuf>, String> {
if let Some(dir) = CONF_DIR.as_deref() {
init_dir(dir).map(Option::Some)
} else {
Ok(None)
}
let Some(dir) = CONF_DIR.as_deref() else {
return Ok(None);
};
// Migrate an existing configuration from the legacy location, if necessary,
// before creating (and thus claiming) the new directory.
migrate_config_dir(legacy_config_dir().as_deref(), dir)?;
init_dir(dir).map(Option::Some)
}

/// Get termscp cache directory path and initialize it.
Expand All @@ -36,21 +101,36 @@ pub fn init_cache_dir() -> Result<Option<PathBuf>, String> {
}
}

/// Init a termscp env dir
/// Moves the legacy configuration directory to the new location when a migration
/// is needed.
///
/// A migration happens only when `new_dir` does not exist yet and `legacy` is
/// `Some` and points to an existing directory; otherwise this is a no-op.
fn migrate_config_dir(legacy: Option<&Path>, new_dir: &Path) -> Result<(), String> {
let Some(legacy) = legacy else {
return Ok(());
};
// Nothing to migrate if the new dir is already there or the legacy one is gone.
if new_dir.exists() || !legacy.exists() {
return Ok(());
}
// Ensure the parent of the new dir exists before moving into it.
if let Some(parent) = new_dir.parent() {
std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
}
std::fs::rename(legacy, new_dir).map_err(|err| err.to_string())
}

/// Init a termscp env dir, creating it if it doesn't already exist.
fn init_dir(p: &Path) -> Result<PathBuf, String> {
// Get path of bookmarks
let mut p: PathBuf = p.to_path_buf();
// Append termscp dir
p.push("termscp/");
// If directory doesn't exist, create it
if p.exists() {
return Ok(p);
}
// directory doesn't exist; create dir recursively
match std::fs::create_dir_all(p.as_path()) {
Ok(_) => Ok(p),
Err(err) => Err(err.to_string()),
// If the directory already exists, there's nothing to do.
if p.is_dir() {
return Ok(p.to_path_buf());
}
// Directory doesn't exist; create it recursively. This fails if the path is
// already occupied by a non-directory file.
std::fs::create_dir_all(p).map_err(|err| err.to_string())?;
Ok(p.to_path_buf())
}

/// Get paths for bookmarks client
Expand Down Expand Up @@ -140,6 +220,58 @@ mod tests {
assert!(std::fs::remove_file(conf_dir.as_path()).is_ok());
}

#[test]
#[serial]
fn should_migrate_legacy_config_dir() {
let base = std::env::temp_dir().join("termscp-migrate-test-move");
let legacy = base.join("legacy");
let new_dir = base.join("new");
// Clean up any leftovers from a previous run
let _ = std::fs::remove_dir_all(&base);
// Set up a legacy dir holding a config file
std::fs::create_dir_all(&legacy).unwrap();
let legacy_file = legacy.join("config.toml");
std::fs::write(&legacy_file, b"hello").unwrap();
// Migrate
assert!(migrate_config_dir(Some(legacy.as_path()), new_dir.as_path()).is_ok());
// Legacy dir is gone, new dir holds the file
assert!(!legacy.exists());
assert!(new_dir.join("config.toml").exists());
// Cleanup
let _ = std::fs::remove_dir_all(&base);
}

#[test]
#[serial]
fn should_not_migrate_when_new_dir_exists() {
let base = std::env::temp_dir().join("termscp-migrate-test-keep");
let legacy = base.join("legacy");
let new_dir = base.join("new");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&legacy).unwrap();
std::fs::create_dir_all(&new_dir).unwrap();
// Both exist: must be a no-op, legacy left untouched
assert!(migrate_config_dir(Some(legacy.as_path()), new_dir.as_path()).is_ok());
assert!(legacy.exists());
let _ = std::fs::remove_dir_all(&base);
}

#[test]
#[serial]
fn should_not_migrate_without_legacy_dir() {
let base = std::env::temp_dir().join("termscp-migrate-test-none");
let new_dir = base.join("new");
let _ = std::fs::remove_dir_all(&base);
// No legacy dir at all
assert!(migrate_config_dir(None, new_dir.as_path()).is_ok());
assert!(!new_dir.exists());
// Legacy provided but missing
let missing = base.join("missing");
assert!(migrate_config_dir(Some(missing.as_path()), new_dir.as_path()).is_ok());
assert!(!new_dir.exists());
let _ = std::fs::remove_dir_all(&base);
}

#[test]
#[serial]
fn test_system_environment_get_bookmarks_paths() {
Expand Down
Loading