M14: store XDG preferences in configuration directory (#1395) (#1536)

Generated with Codex for @DWAA1660.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DWAA1660
2026-10-15 03:52:12 -04:00
committed by GitHub
co-authored by Brandon Thomas Claude Opus 5.5
parent 57c8de09ab
commit 3805be7669
7 changed files with 238 additions and 15 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
//! Without a logger every `log::warn!`/`log::error!` in the workspace (a failed autosave, a page
//! that would not render, a render worker that could not start, the panic hook's report) vanished.
//! A launch from a desktop menu has no terminal, so the file is what a bug report can attach:
//! `<settings dir>/logs/pdfcraft.log`, next to `app.ron` (see `settings_dir` in `main.rs`). Each
//! `<data dir>/logs/pdfcraft.log` (the PdfCraft eframe storage directory, or portable data). Each
//! start moves the previous log to `pdfcraft.1.log` (and that one to `.2`), so the log of a run
//! that crashed survives the next launch.
//!
+22 -9
View File
@@ -37,6 +37,7 @@ mod logging;
#[cfg(target_os = "windows")]
mod motion;
mod reopen;
mod settings;
mod single_instance;
mod text_scale;
mod updates;
@@ -57,14 +58,24 @@ const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256
/// The app was called PrintCraft before; settings saved then are under this key.
const LEGACY_STORAGE_KEY: &str = "printcraft";
/// The settings folder: `app.ron` and the `logs` folder (docs/development.md). In portable mode
/// it is `PdfCraftData` beside the executable (#157). eframe would otherwise derive it from the
/// app id; keep it under "PdfCraft".
/// The preferences folder (`app.ron`, docs/development.md). In portable mode
/// it is `PdfCraftData` beside the executable (#157). XDG desktops use the configuration base;
/// other platforms retain eframe's PdfCraft folder. Logs and recovery stay in the data folder.
fn settings_dir() -> Option<std::path::PathBuf> {
if let Some(dir) = pdfcraft_ui_egui::portable::data_dir() {
return Some(dir.to_path_buf());
let legacy = eframe::storage_dir("PdfCraft");
match settings::directory(
cfg!(all(unix, not(target_os = "macos"))),
pdfcraft_ui_egui::portable::data_dir(),
legacy.clone(),
std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from),
std::env::home_dir(),
) {
Ok(dir) => dir,
Err(e) => {
log::warn!("could not migrate preferences to the XDG configuration folder; using the original location: {e}");
legacy
}
}
eframe::storage_dir("PdfCraft")
}
/// Move the settings and crash-recovery folders of the app's former name, PrintCraft, to the new
@@ -75,7 +86,7 @@ fn migrate_legacy_folders() {
if pdfcraft_ui_egui::portable::data_dir().is_some() {
return;
}
let mut moves = vec![(eframe::storage_dir("PrintCraft"), settings_dir())];
let mut moves = vec![(eframe::storage_dir("PrintCraft"), eframe::storage_dir("PdfCraft"))];
// Recovery lives in the settings folder except on Windows, where it is under %LOCALAPPDATA%.
if cfg!(windows) {
let local = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
@@ -203,10 +214,12 @@ fn main() -> eframe::Result {
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
migrate_legacy_folders();
// The log file lives in the settings folder; opened after the arguments (so `--version` leaves
// The log file stays in the data folder; opened after the arguments (so `--version` leaves
// no file behind) and after the PrintCraft migration (which a fresh folder would block).
// Records logged until now are written to it first.
if let (Some(logger), Some(dir)) = (logger, settings_dir()) {
if let (Some(logger), Some(dir)) =
(logger, pdfcraft_ui_egui::portable::data_dir().map(std::path::Path::to_path_buf).or_else(|| eframe::storage_dir("PdfCraft")))
{
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("PdfCraft {}, log file {}", pdfcraft_engine::build_info::version(), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
+194
View File
@@ -0,0 +1,194 @@
//! Platform preference location, separate from logs and crash recovery.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static STAGING_ID: AtomicU64 = AtomicU64::new(0);
pub fn directory(
xdg: bool,
portable: Option<&Path>,
legacy: Option<PathBuf>,
config_home: Option<PathBuf>,
home: Option<PathBuf>,
) -> std::io::Result<Option<PathBuf>> {
if let Some(portable) = portable {
return Ok(Some(portable.to_path_buf()));
}
if !xdg {
return Ok(legacy);
}
// XDG requires absolute environment paths; empty and relative overrides are ignored.
let preferred = config_home.filter(|p| p.is_absolute()).or_else(|| home.filter(|p| p.is_absolute()).map(|p| p.join(".config")));
let Some(preferred) = preferred.map(|p| p.join("pdfcraft")) else { return Ok(legacy) };
let target = preferred.join("app.ron");
if std::fs::symlink_metadata(&target).is_ok() {
return Ok(Some(preferred));
}
create_private_directory(&preferred)?;
if let Some(source) = legacy.map(|p| p.join("app.ron")) {
match std::fs::File::open(&source) {
Ok(mut source) => migrate(&mut source, &preferred, &target)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
Ok(Some(preferred))
}
/// Create private configuration directories without changing existing directory permissions.
fn create_private_directory(directory: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(directory)
}
/// Publish a complete copy without replacing a concurrent launch's preferences. Keep the
/// original file and all other data (especially recovery and digital IDs) where they were.
fn migrate(source: &mut std::fs::File, directory: &Path, target: &Path) -> std::io::Result<()> {
create_private_directory(directory)?;
for _ in 0..32 {
let id = STAGING_ID.fetch_add(1, Ordering::Relaxed);
let staging = directory.join(format!(".app.ron-migrate-{}-{id}", std::process::id()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = match options.open(&staging) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
};
let result = (|| {
std::io::copy(source, &mut file)?;
file.sync_all()?;
// Both files are in the same directory/filesystem. Unlike rename, hard_link
// atomically refuses an existing destination on Unix as well as Windows.
match std::fs::hard_link(&staging, target) {
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
result => result,
}
})();
drop(file);
let _ = std::fs::remove_file(&staging);
return result;
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free settings migration staging name"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xdg_preferences_use_config_instead_of_data() {
let fixture = Fixture::new();
let home = fixture.0.join("home");
let data = home.join(".local/share/pdfcraft");
assert_eq!(directory(true, None, Some(data), None, Some(home.clone())).unwrap(), Some(home.join(".config/pdfcraft")));
}
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let id = STAGING_ID.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("pdfcraft-settings-{}-{id}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn xdg_override_and_invalid_overrides() {
let f = Fixture::new();
let home = f.0.join("home");
let config = f.0.join("custom-config");
assert_eq!(directory(true, None, None, Some(config.clone()), Some(home.clone())).unwrap(), Some(config.join("pdfcraft")));
for invalid in [PathBuf::new(), PathBuf::from("relative")] {
assert_eq!(directory(true, None, None, Some(invalid), Some(home.clone())).unwrap(), Some(home.join(".config/pdfcraft")));
}
assert_eq!(directory(true, None, Some(f.0.clone()), None, None).unwrap(), Some(f.0.clone()));
}
#[test]
fn migration_preserves_preferences_and_other_data() {
let f = Fixture::new();
let data = f.0.join("data/pdfcraft");
let config = f.0.join("config");
std::fs::create_dir_all(data.join("recovery")).unwrap();
std::fs::write(data.join("app.ron"), b"(theme:Dark,language:German)").unwrap();
std::fs::write(data.join("recovery/unsaved.pdf"), b"UNSAVED").unwrap();
std::fs::write(data.join("digital-id.p12"), b"KEY").unwrap();
let selected = directory(true, None, Some(data.clone()), Some(config.clone()), None).unwrap().unwrap();
assert_eq!(selected, config.join("pdfcraft"));
assert_eq!(std::fs::read(selected.join("app.ron")).unwrap(), std::fs::read(data.join("app.ron")).unwrap());
assert_eq!(std::fs::read(data.join("recovery/unsaved.pdf")).unwrap(), b"UNSAVED");
assert_eq!(std::fs::read(data.join("digital-id.p12")).unwrap(), b"KEY");
// The migrated file is a copy, not a link to the original settings.
std::fs::write(selected.join("app.ron"), b"NEW").unwrap();
assert_eq!(std::fs::read(data.join("app.ron")).unwrap(), b"(theme:Dark,language:German)");
assert_eq!(directory(true, None, Some(data), Some(config), None).unwrap(), Some(selected.clone()));
assert_eq!(std::fs::read(selected.join("app.ron")).unwrap(), b"NEW");
assert_eq!(std::fs::read_dir(selected).unwrap().count(), 1);
}
#[test]
fn existing_or_concurrently_created_preferences_are_never_replaced() {
let f = Fixture::new();
let source = f.0.join("old.ron");
let config = f.0.join("config/pdfcraft");
std::fs::write(&source, b"OLD").unwrap();
std::fs::create_dir_all(&config).unwrap();
let target = config.join("app.ron");
std::fs::write(&target, b"CURRENT").unwrap();
migrate(&mut std::fs::File::open(source).unwrap(), &config, &target).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"CURRENT");
assert_eq!(std::fs::read_dir(config).unwrap().count(), 1);
}
#[test]
fn failed_migration_keeps_original_available() {
let f = Fixture::new();
std::fs::write(f.0.join("app.ron"), b"OLD").unwrap();
let blocked = f.0.join("blocked");
std::fs::write(&blocked, b"NOT A DIRECTORY").unwrap();
assert!(directory(true, None, Some(f.0.clone()), Some(blocked), None).is_err());
assert_eq!(std::fs::read(f.0.join("app.ron")).unwrap(), b"OLD");
}
#[test]
fn portable_and_non_xdg_platforms_keep_their_locations() {
let f = Fixture::new();
let portable = f.0.join("PdfCraftData");
for xdg in [true, false] {
assert_eq!(directory(xdg, Some(&portable), Some(f.0.clone()), Some(f.0.clone()), None).unwrap(), Some(portable.clone()));
}
assert_eq!(directory(false, None, Some(f.0.clone()), Some(portable), None).unwrap(), Some(f.0.clone()));
assert_eq!(std::fs::read_dir(&f.0).unwrap().count(), 0, "no migration outside XDG mode");
}
#[cfg(unix)]
#[test]
fn new_config_directory_is_private_and_existing_permissions_are_preserved() {
use std::os::unix::fs::PermissionsExt;
let f = Fixture::new();
let selected = directory(true, None, None, Some(f.0.join("config")), None).unwrap().unwrap();
assert_eq!(std::fs::metadata(&selected).unwrap().permissions().mode() & 0o777, 0o700);
std::fs::set_permissions(&selected, std::fs::Permissions::from_mode(0o750)).unwrap();
create_private_directory(&selected).unwrap();
assert_eq!(std::fs::metadata(selected).unwrap().permissions().mode() & 0o777, 0o750);
}
}