Merge remote-tracking branch 'origin/main' into codex/craft-print-fix-merge-20261012T210558Z
# Conflicts: # Cargo.lock
This commit is contained in:
Generated
+57
@@ -543,6 +543,17 @@ dependencies = [
|
||||
"objc2-app-kit 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-settings"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-atomic",
|
||||
"dirs",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -653,6 +664,27 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -2379,6 +2411,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -2823,6 +2861,16 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
|
||||
dependencies = [
|
||||
"libredox",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "renderdoc-sys"
|
||||
version = "1.1.0"
|
||||
@@ -3884,8 +3932,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
@@ -3898,6 +3949,12 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
|
||||
@@ -14,6 +14,7 @@ repository = "https://github.com/storytold/craft-libs"
|
||||
craft-atomic = { path = "crates/atomic" }
|
||||
craft-print = { path = "crates/print" }
|
||||
craft-quit = { path = "crates/quit" }
|
||||
craft-settings = { path = "crates/settings" }
|
||||
craft-ui = { path = "crates/ui" }
|
||||
egui = { version = "0.36", default-features = false }
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-quit`](crates/quit/src/lib.rs): native macOS quit, egui close handling and an optional
|
||||
app menu. Unsaved-document decisions and save prompts stay in each app.
|
||||
|
||||
- [`craft-settings`](crates/settings/README.md): `config.toml` settings storage for every app: platform folders, portable mode and overrides, atomic saves while running, backup, damaged-file recovery and schema migrations. Policy in [docs/settings-storage.md](docs/settings-storage.md).
|
||||
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking, print settings and theme helpers for egui frontends;
|
||||
assets, themes, localization and command handling stay in each app. Requires Rust 1.95 or later.
|
||||
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "craft-settings"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Shared settings storage for the Craft suite: platform folders, config.toml, atomic saves, recovery and migration"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-atomic = { workspace = true }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml_edit = { version = "0.25", features = ["serde"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
dirs = "7"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,182 @@
|
||||
# Craft Settings
|
||||
|
||||
`craft-settings` stores an application's settings in `config.toml`: it picks the folder, loads
|
||||
without ever failing, saves atomically while the application runs, keeps a backup, moves damaged
|
||||
files aside and runs schema migrations. Each application keeps its own settings type and
|
||||
defaults. The suite-wide policy (what belongs where) is in
|
||||
[docs/settings-storage.md](../../docs/settings-storage.md).
|
||||
|
||||
Atomic writes come from [`craft-atomic`](../atomic/README.md).
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Never crashes: every error comes back in a `LoadReport`, `TickResult` or `Result`; the crate
|
||||
denies `unwrap`, `expect`, `panic` and friends, and the workspace forbids `unsafe`.
|
||||
- No application code: it depends only on `craft-atomic`, `serde`, `toml_edit` and `dirs`, and
|
||||
knows nothing about any app's settings, defaults, UI or file formats. Apps supply their type
|
||||
and their own parsers for older files.
|
||||
- No UI: it reports; the application decides what to show.
|
||||
|
||||
## Defining settings
|
||||
|
||||
```rust
|
||||
use craft_settings::{DocumentMut, MigrateError, Settings};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
|
||||
#[serde(default)]
|
||||
struct Prefs {
|
||||
general: General,
|
||||
appearance: Appearance,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
|
||||
#[serde(default)]
|
||||
struct General { language: String, autosave_minutes: u32 }
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
|
||||
#[serde(default)]
|
||||
struct Appearance { theme: String, ui_scale: f64 }
|
||||
|
||||
impl Settings for Prefs {
|
||||
const APP_ID: &'static str = "photocraft";
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
fn migrate(from: u32, doc: &mut DocumentMut) -> Result<(), MigrateError> {
|
||||
// Called once per step for files older than SCHEMA_VERSION, before typed parsing.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules for the type:
|
||||
|
||||
- Put `#[serde(default)]` on every struct, so a missing key falls back to its default.
|
||||
- Do not use `#[serde(deny_unknown_fields)]`: keys the application does not know are kept in
|
||||
the file on purpose.
|
||||
- Each top-level field becomes a `[section]`. A value that does not fit resets only its section.
|
||||
|
||||
## Using the store
|
||||
|
||||
```rust,ignore
|
||||
use craft_settings::{Location, SaveStatus, SettingsStore};
|
||||
|
||||
// Once at startup. Never fails; look at the report for anything to show the user.
|
||||
let (mut store, report) = SettingsStore::<Prefs>::open(Location::Auto);
|
||||
if report.has_problems() {
|
||||
// report.broken_moved_to, report.section_errors, report.read_only
|
||||
}
|
||||
|
||||
// Every frame: read is in-memory.
|
||||
let scale = store.get().appearance.ui_scale;
|
||||
|
||||
// On change: only marks the store dirty if the value actually changed.
|
||||
store.update(|p| p.appearance.theme = "dark".into());
|
||||
|
||||
// Every frame: saves 500 ms after the last change, retries failures with backoff.
|
||||
let tick = store.tick(std::time::Instant::now());
|
||||
if let SaveStatus::Failed(err) = &tick.status {
|
||||
// show it; the store keeps retrying
|
||||
}
|
||||
if let Some(deadline) = tick.next_deadline {
|
||||
// eframe only repaints on input, so ask for one at the deadline or an idle app never saves:
|
||||
ctx.request_repaint_after(deadline.saturating_duration_since(std::time::Instant::now()));
|
||||
}
|
||||
|
||||
// On exit.
|
||||
let _ = store.flush();
|
||||
```
|
||||
|
||||
`SettingsStore::open_at(dir)` opens a store in a given folder, for settings that travel with a
|
||||
library or project. `SettingsStore::open(Location::Memory)` keeps settings in memory only and
|
||||
reads and writes nothing, for tests, demos and switches like `DESIGNCRAFT_NO_PREFS`; the app
|
||||
uses the same `get`/`update`/`tick`/`flush` calls either way. `OpenOptions` adds older files to
|
||||
import, a different debounce, and a `Probe` for tests.
|
||||
|
||||
## Where the file goes
|
||||
|
||||
`Location::Auto` applies these rules in order; the report says which one won:
|
||||
|
||||
1. `Location::Dir(path)`: an application override, such as a `--config-dir` flag.
|
||||
2. `<APP>_CONFIG_DIR` (`PHOTOCRAFT_CONFIG_DIR`). Set but **empty** means isolated: the store
|
||||
is in memory only and nothing is read or written, so tests and agents never touch the real
|
||||
profile.
|
||||
3. A portable marker next to the executable (next to the `.app` bundle on macOS): by default a
|
||||
file named `portable`, with settings in `config/` beside it. An app with an existing portable
|
||||
layout keeps it by overriding `Settings::PORTABLE_MARKERS` and `Settings::PORTABLE_DIR`:
|
||||
|
||||
```rust,ignore
|
||||
const PORTABLE_MARKERS: &'static [&'static str] = &["portable.txt", "PhotoCraft.portable"];
|
||||
const PORTABLE_DIR: &'static str = "PhotoCraftData";
|
||||
```
|
||||
4. The platform config folder: `$XDG_CONFIG_HOME/<app>/` or `~/.config/<app>/` on Linux,
|
||||
`~/Library/Application Support/<app>/` on macOS, `%APPDATA%\<app>\` on Windows.
|
||||
|
||||
With no folder (isolated or `Location::Memory`), `store.dir()` and `store.path()` are `None`,
|
||||
`is_persistent()` is false, legacy files are not imported, and `flush()` returns `Ok`.
|
||||
|
||||
Otherwise the folder is created at open. If it cannot be created or written, the store opens read-only
|
||||
and the report says so, so the application can tell the user at startup rather than on the
|
||||
first save. `paths::cache_dir` and `paths::data_dir` give the matching cache and data folders for
|
||||
things that do not belong in `config.toml`.
|
||||
|
||||
## Files
|
||||
|
||||
- `config.toml`: the settings, starting with `schema_version = N`. Written on the first
|
||||
`flush()` even when nothing changed, so users can find it. Created `0600` on Unix; a mode the
|
||||
user changes by hand is kept on later saves.
|
||||
- `config.toml.bak`: the previous version, refreshed before each save. On Unix it always has
|
||||
the same mode as `config.toml`; an older, broader backup is tightened on the next save.
|
||||
- `config.broken-<unix-ts>.toml`: a file that was not valid TOML, moved aside. Never deleted.
|
||||
|
||||
## What happens when loading
|
||||
|
||||
| File state | Result |
|
||||
|---|---|
|
||||
| Missing | Defaults, or the first `LegacyFile` that parses. The old file is left in place so a rollback still works. |
|
||||
| Not valid TOML | Moved aside; defaults; `report.broken_moved_to`. If it cannot be moved, read-only. |
|
||||
| Valid TOML, a section does not fit | That section's defaults; the rest kept; `report.section_errors`. |
|
||||
| Older `schema_version` | `Settings::migrate` once per step, then saved; `report.migrated_from`. |
|
||||
| Newer `schema_version` | Values shown, store read-only, so an older build never overwrites a newer file. |
|
||||
| Migration step fails | Store read-only, file untouched. |
|
||||
|
||||
## What happens when saving
|
||||
|
||||
Values are merged into the document that was loaded: tables merge key by key, arrays and
|
||||
scalars are replaced whole, comments, key order, unknown keys and each replaced line's trailing
|
||||
comment stay. Each write goes through `craft-atomic`, so a failed save leaves the old file intact.
|
||||
|
||||
Just before each write the store reads the file and compares its **contents** (not its mtime,
|
||||
which can stay the same on FAT, HFS+ or network shares) with what it last read or wrote:
|
||||
|
||||
| File on disk | What the save does |
|
||||
|---|---|
|
||||
| Unchanged | Backs it up to `config.toml.bak`, then writes. |
|
||||
| Changed, parses | Adopts it so its comments and unknown keys survive, backs it up, then writes this store's values over its known keys. |
|
||||
| Changed, does not parse | Moves it to `config.broken-<ts>.toml` (the edit is kept; `take_moved_aside()` returns the path to show), leaves the previous backup alone, then writes. |
|
||||
| Blank | Leaves the previous backup alone, then writes. |
|
||||
| From a newer build, or its migration fails | Writes nothing; the store becomes read-only and the save returns `SettingsError::ReadOnly`, the same as at startup. |
|
||||
|
||||
### Concurrency contract
|
||||
|
||||
Saving is **best effort, last writer wins**, not multi-process locking. Hand edits and a
|
||||
second instance's unknown keys and comments are preserved as above, but two processes saving
|
||||
within the same moment can still race: the later write wins on known keys. Settings are small
|
||||
and change rarely, so this is the contract apps should assume. An app that needs strict
|
||||
coordination between processes (for example a launcher writing another app's settings) should
|
||||
add its own lock around `flush()`.
|
||||
|
||||
## Validation
|
||||
|
||||
Before merging (from `AGENTS.md`), plus the wasm check:
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
cargo check --target wasm32-unknown-unknown -p craft-settings
|
||||
```
|
||||
|
||||
The tests in `tests/` write only to temporary folders and never touch the real config folder.
|
||||
|
||||
On `wasm32` only the `Settings` trait, the document helpers and the error types are available.
|
||||
@@ -0,0 +1,109 @@
|
||||
//! TOML document handling: `schema_version`, per-section parsing and comment-preserving merges.
|
||||
|
||||
use toml_edit::{DocumentMut, Item, Table, TableLike};
|
||||
|
||||
use crate::Settings;
|
||||
use crate::error::SectionError;
|
||||
|
||||
/// The key written first in every `config.toml`.
|
||||
pub const SCHEMA_VERSION_KEY: &str = "schema_version";
|
||||
|
||||
/// Parses `text` as TOML, keeping comments and formatting.
|
||||
pub fn parse(text: &str) -> Result<DocumentMut, toml_edit::TomlError> {
|
||||
text.parse()
|
||||
}
|
||||
|
||||
/// Reads `schema_version`; `None` when the key is missing or not a non-negative integer.
|
||||
pub fn schema_version(doc: &DocumentMut) -> Option<u32> {
|
||||
doc.get(SCHEMA_VERSION_KEY)?.as_integer().and_then(|v| u32::try_from(v).ok())
|
||||
}
|
||||
|
||||
/// Sets `schema_version`, keeping its position if it already exists.
|
||||
pub fn set_schema_version(doc: &mut DocumentMut, version: u32) {
|
||||
doc[SCHEMA_VERSION_KEY] = toml_edit::value(i64::from(version));
|
||||
}
|
||||
|
||||
/// An empty document carrying only `schema_version`.
|
||||
pub fn new_document(version: u32) -> DocumentMut {
|
||||
let mut doc = DocumentMut::new();
|
||||
set_schema_version(&mut doc, version);
|
||||
doc
|
||||
}
|
||||
|
||||
/// Turns `doc` into `T`, one top-level section at a time.
|
||||
///
|
||||
/// A section that does not fit `T` is reported and replaced by its default; every other section
|
||||
/// is kept. This is what keeps a renamed enum variant from resetting the whole file.
|
||||
pub fn deserialize_sections<T: Settings>(doc: &DocumentMut) -> (T, Vec<SectionError>) {
|
||||
let defaults = T::default();
|
||||
let Ok(mut merged) = toml_edit::ser::to_document(&defaults) else {
|
||||
return (defaults, Vec::new());
|
||||
};
|
||||
let mut errors = Vec::new();
|
||||
for (key, item) in doc.iter() {
|
||||
if key == SCHEMA_VERSION_KEY {
|
||||
continue;
|
||||
}
|
||||
let mut candidate = merged.clone();
|
||||
candidate[key] = item.clone();
|
||||
match toml_edit::de::from_document::<T>(candidate.clone()) {
|
||||
Ok(_) => merged = candidate,
|
||||
Err(e) => errors.push(SectionError { section: key.to_owned(), message: e.to_string() }),
|
||||
}
|
||||
}
|
||||
let value = toml_edit::de::from_document::<T>(merged).unwrap_or(defaults);
|
||||
(value, errors)
|
||||
}
|
||||
|
||||
/// Writes `value` into `doc`, keeping comments, key order and keys `value` does not have.
|
||||
///
|
||||
/// Tables merge key by key; arrays, arrays of tables and scalars are replaced whole.
|
||||
pub fn merge_into<T: Settings>(doc: &mut DocumentMut, value: &T) -> Result<(), toml_edit::ser::Error> {
|
||||
let mut fresh = toml_edit::ser::to_document(value)?;
|
||||
expand_inline_tables(fresh.as_table_mut());
|
||||
merge_tables(doc.as_table_mut(), fresh.as_table());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serde writes nested structs as inline tables (`general = { … }`); files are nicer to read
|
||||
/// and edit as `[general]` sections, so new sections are converted before merging.
|
||||
fn expand_inline_tables(table: &mut Table) {
|
||||
let keys: Vec<String> = table.iter().map(|(k, _)| k.to_owned()).collect();
|
||||
for key in keys {
|
||||
let Some(item) = table.get_mut(&key) else { continue };
|
||||
if let Some(inline) = item.as_inline_table() {
|
||||
*item = Item::Table(inline.clone().into_table());
|
||||
}
|
||||
if let Some(sub) = item.as_table_mut() {
|
||||
expand_inline_tables(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_tables(into: &mut dyn TableLike, from: &dyn TableLike) {
|
||||
for (key, item) in from.iter() {
|
||||
match into.get_mut(key) {
|
||||
Some(existing) if existing.is_table_like() && item.is_table_like() => {
|
||||
if let (Some(existing), Some(fresh)) = (existing.as_table_like_mut(), item.as_table_like()) {
|
||||
merge_tables(existing, fresh);
|
||||
}
|
||||
}
|
||||
Some(existing) => {
|
||||
// Keep the spacing and trailing comment of the line being replaced.
|
||||
let mut fresh = item.clone();
|
||||
if let (Some(old), Some(new)) = (existing.as_value(), fresh.as_value_mut()) {
|
||||
*new.decor_mut() = old.decor().clone();
|
||||
}
|
||||
*existing = fresh;
|
||||
}
|
||||
None => {
|
||||
into.insert(key, item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders `doc` as text.
|
||||
pub fn render(doc: &DocumentMut) -> String {
|
||||
doc.to_string()
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Reports and errors returned by the store.
|
||||
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Why a schema migration step failed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MigrateError {
|
||||
/// The `schema_version` the step started from.
|
||||
pub from: u32,
|
||||
/// What went wrong.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl MigrateError {
|
||||
/// A failure migrating from version `from`.
|
||||
pub fn new(from: u32, message: impl Into<String>) -> Self {
|
||||
Self { from, message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MigrateError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "migration from schema version {} failed: {}", self.from, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MigrateError {}
|
||||
|
||||
/// A `[section]` that parsed as TOML but did not fit the settings type; its defaults were used.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SectionError {
|
||||
/// The top-level key.
|
||||
pub section: String,
|
||||
/// The deserialization error.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Why the store will not write `config.toml`.
|
||||
#[derive(Debug)]
|
||||
pub enum ReadOnlyReason {
|
||||
/// The configuration folder could not be created.
|
||||
DirUnavailable(io::Error),
|
||||
/// The configuration folder exists but is not writable.
|
||||
DirNotWritable(io::Error),
|
||||
/// `config.toml` exists but could not be read.
|
||||
Unreadable(io::Error),
|
||||
/// `config.toml` is not valid TOML and could not be moved aside.
|
||||
CannotMoveAside(io::Error),
|
||||
/// The file was written by a newer build.
|
||||
NewerSchema {
|
||||
/// Version in the file.
|
||||
file: u32,
|
||||
/// Version this build knows.
|
||||
app: u32,
|
||||
},
|
||||
/// A schema migration step failed; the file is left untouched.
|
||||
Migration(MigrateError),
|
||||
}
|
||||
|
||||
impl fmt::Display for ReadOnlyReason {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::DirUnavailable(e) => write!(f, "settings folder cannot be created: {e}"),
|
||||
Self::DirNotWritable(e) => write!(f, "settings folder is not writable: {e}"),
|
||||
Self::Unreadable(e) => write!(f, "config.toml cannot be read: {e}"),
|
||||
Self::CannotMoveAside(e) => write!(f, "config.toml is damaged and cannot be moved aside: {e}"),
|
||||
Self::NewerSchema { file, app } => write!(f, "config.toml has schema version {file}, newer than this build's {app}"),
|
||||
Self::Migration(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the loaded values came from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// Parsed from `config.toml`.
|
||||
File,
|
||||
/// `config.toml` did not exist or was unusable; defaults were used.
|
||||
Defaults,
|
||||
/// Imported from an older per-app file (left in place).
|
||||
Legacy(PathBuf),
|
||||
}
|
||||
|
||||
/// What happened while opening the store. Nothing in here is fatal; the store always opens.
|
||||
#[derive(Debug)]
|
||||
pub struct LoadReport {
|
||||
/// The configuration folder; `None` when nothing is read or written (isolated or in memory).
|
||||
pub dir: Option<PathBuf>,
|
||||
/// The rule that chose `dir` (see [`crate::Rule`]), as text so the report stays `Debug`-only.
|
||||
pub rule: String,
|
||||
/// Where the values came from.
|
||||
pub source: Source,
|
||||
/// Set when a damaged `config.toml` was renamed; the new name.
|
||||
pub broken_moved_to: Option<PathBuf>,
|
||||
/// Sections whose values were replaced by defaults.
|
||||
pub section_errors: Vec<SectionError>,
|
||||
/// Set when the file was upgraded from an older `schema_version`.
|
||||
pub migrated_from: Option<u32>,
|
||||
/// Set when the store will not save.
|
||||
pub read_only: Option<ReadOnlyReason>,
|
||||
}
|
||||
|
||||
impl LoadReport {
|
||||
/// True when something should be shown to the user.
|
||||
pub fn has_problems(&self) -> bool {
|
||||
self.broken_moved_to.is_some() || !self.section_errors.is_empty() || self.read_only.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// A save failure.
|
||||
#[derive(Debug)]
|
||||
pub enum SettingsError {
|
||||
/// The store is read-only; see the reason in the load report.
|
||||
ReadOnly(String),
|
||||
/// The settings could not be turned into TOML.
|
||||
Serialize(String),
|
||||
/// Writing `config.toml` failed.
|
||||
Io(io::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for SettingsError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::ReadOnly(why) => write!(f, "settings are read-only: {why}"),
|
||||
Self::Serialize(e) => write!(f, "settings cannot be serialized: {e}"),
|
||||
Self::Io(e) => write!(f, "settings could not be saved: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SettingsError {}
|
||||
|
||||
impl From<io::Error> for SettingsError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of one [`crate::SettingsStore::tick`].
|
||||
#[derive(Debug)]
|
||||
pub enum SaveStatus {
|
||||
/// Nothing to save.
|
||||
Idle,
|
||||
/// Changes are waiting for the debounce or retry delay.
|
||||
Pending,
|
||||
/// A save just succeeded.
|
||||
Saved,
|
||||
/// A save just failed; it will be retried.
|
||||
Failed(SettingsError),
|
||||
}
|
||||
|
||||
/// What [`crate::SettingsStore::tick`] returns.
|
||||
#[derive(Debug)]
|
||||
pub struct TickResult {
|
||||
/// What happened this tick.
|
||||
pub status: SaveStatus,
|
||||
/// When to call `tick` again so a pending save happens even if the app is idle.
|
||||
/// Pass it to `egui::Context::request_repaint_after`.
|
||||
pub next_deadline: Option<Instant>,
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! Shared settings storage for the Craft suite.
|
||||
//!
|
||||
//! Each application keeps its own settings type and defaults. This crate decides where the
|
||||
//! `config.toml` lives, loads it without ever failing, saves it atomically while the
|
||||
//! application runs, keeps a backup, moves damaged files aside and runs schema migrations.
|
||||
//!
|
||||
//! Hand edits survive: the file is kept in memory as a [`toml_edit::DocumentMut`] and saves
|
||||
//! merge values into it, so comments and keys the application does not know stay in place.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use craft_settings::{Location, Settings, SettingsStore};
|
||||
//! use serde::{Deserialize, Serialize};
|
||||
//!
|
||||
//! #[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
|
||||
//! #[serde(default)]
|
||||
//! struct Prefs {
|
||||
//! appearance: Appearance,
|
||||
//! }
|
||||
//!
|
||||
//! #[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
|
||||
//! #[serde(default)]
|
||||
//! struct Appearance {
|
||||
//! theme: String,
|
||||
//! }
|
||||
//!
|
||||
//! impl Settings for Prefs {
|
||||
//! const APP_ID: &'static str = "photocraft";
|
||||
//! const SCHEMA_VERSION: u32 = 1;
|
||||
//! }
|
||||
//!
|
||||
//! let (mut store, report) = SettingsStore::<Prefs>::open(Location::Auto);
|
||||
//! store.update(|p| p.appearance.theme = "dark".into());
|
||||
//! // Call `store.tick(Instant::now())` every frame and `store.flush()` on exit.
|
||||
//! ```
|
||||
|
||||
pub mod document;
|
||||
pub mod error;
|
||||
pub mod migrate;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod paths;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod recovery;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod store;
|
||||
|
||||
pub use error::{LoadReport, MigrateError, ReadOnlyReason, SaveStatus, SectionError, SettingsError, Source, TickResult};
|
||||
pub use migrate::LegacyFile;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use paths::{Location, Portable, Probe, Resolved, Rule};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use store::{OpenOptions, SettingsStore};
|
||||
pub use toml_edit::DocumentMut;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
/// Suite-default portable names, kept here so the trait also compiles on wasm.
|
||||
mod paths_default {
|
||||
pub const MARKERS: &[&str] = &["portable"];
|
||||
pub const DIR: &str = "config";
|
||||
}
|
||||
|
||||
/// An application's settings type.
|
||||
///
|
||||
/// Derive `Serialize`, `Deserialize`, `Default`, `Clone` and `PartialEq`, and put
|
||||
/// `#[serde(default)]` on every struct so missing keys fall back to defaults. Do not use
|
||||
/// `#[serde(deny_unknown_fields)]`: unknown keys are kept in the file on purpose.
|
||||
pub trait Settings: Serialize + DeserializeOwned + Default + Clone + PartialEq {
|
||||
/// Lowercase application id, used for the folder name and the `<APP>_CONFIG_DIR` variable.
|
||||
const APP_ID: &'static str;
|
||||
|
||||
/// Version written as `schema_version` at the top of `config.toml`.
|
||||
const SCHEMA_VERSION: u32;
|
||||
|
||||
/// File names next to the executable that turn on portable mode (any one; contents ignored).
|
||||
/// Override to keep an existing portable layout, e.g. `&["portable.txt", "PhotoCraft.portable"]`.
|
||||
const PORTABLE_MARKERS: &'static [&'static str] = paths_default::MARKERS;
|
||||
|
||||
/// Folder beside the executable that holds settings in portable mode.
|
||||
const PORTABLE_DIR: &'static str = paths_default::DIR;
|
||||
|
||||
/// Upgrade a document from `from` to `from + 1`. Called once per step for files whose
|
||||
/// `schema_version` is older than [`Settings::SCHEMA_VERSION`], before typed parsing.
|
||||
fn migrate(from: u32, doc: &mut DocumentMut) -> Result<(), MigrateError> {
|
||||
let _ = (from, doc);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Schema migrations and import of older per-app settings files.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
use crate::Settings;
|
||||
use crate::document::{schema_version, set_schema_version};
|
||||
use crate::error::MigrateError;
|
||||
|
||||
/// What [`upgrade`] found.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Upgrade {
|
||||
/// Already at the current version, or no `schema_version` key (treated as current).
|
||||
Current,
|
||||
/// Upgraded step by step from this older version.
|
||||
Migrated { from: u32 },
|
||||
/// Written by a newer build; the document was left alone.
|
||||
Newer { file: u32 },
|
||||
}
|
||||
|
||||
/// Brings `doc` up to [`Settings::SCHEMA_VERSION`] by calling [`Settings::migrate`] once per step.
|
||||
///
|
||||
/// A missing `schema_version` means a hand-written file; it is taken as current and the key is
|
||||
/// added. A failed step leaves the document as that step left it; callers must not save it.
|
||||
pub fn upgrade<T: Settings>(doc: &mut DocumentMut) -> Result<Upgrade, MigrateError> {
|
||||
let target = T::SCHEMA_VERSION;
|
||||
let Some(file) = schema_version(doc) else {
|
||||
set_schema_version(doc, target);
|
||||
return Ok(Upgrade::Current);
|
||||
};
|
||||
if file > target {
|
||||
return Ok(Upgrade::Newer { file });
|
||||
}
|
||||
if file == target {
|
||||
return Ok(Upgrade::Current);
|
||||
}
|
||||
for step in file..target {
|
||||
T::migrate(step, doc)?;
|
||||
set_schema_version(doc, step + 1);
|
||||
}
|
||||
Ok(Upgrade::Migrated { from: file })
|
||||
}
|
||||
|
||||
/// An older settings file an application used before `config.toml`.
|
||||
///
|
||||
/// The application parses it with its own code, so this crate needs no JSON or RON support.
|
||||
/// The file is read, never renamed or removed, so an older build keeps working after a rollback.
|
||||
pub struct LegacyFile<T> {
|
||||
/// Where the old file lives on this platform.
|
||||
pub path: PathBuf,
|
||||
/// Parses the file's bytes into the current settings type.
|
||||
pub parse: fn(&[u8]) -> Result<T, String>,
|
||||
}
|
||||
|
||||
impl<T> std::fmt::Debug for LegacyFile<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("LegacyFile").field("path", &self.path).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries each legacy file in order and returns the first one that parses, with its path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn import_legacy<T>(files: &[LegacyFile<T>]) -> Option<(T, PathBuf)> {
|
||||
files.iter().find_map(|f| {
|
||||
let bytes = std::fs::read(&f.path).ok()?;
|
||||
(f.parse)(&bytes).ok().map(|v| (v, f.path.clone()))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Choosing the configuration folder.
|
||||
//!
|
||||
//! First match wins: an override from the application, the `<APP>_CONFIG_DIR` variable, a
|
||||
//! portable marker next to the executable, then the platform's own config folder. An empty
|
||||
//! `<APP>_CONFIG_DIR`, or [`Location::Memory`], means no folder at all: nothing is read or written.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How an application spells portable mode: the marker files that turn it on (any one, contents
|
||||
/// ignored) and the folder created beside the executable. Comes from [`crate::Settings`], so an
|
||||
/// app that already ships portable builds keeps its existing names.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Portable {
|
||||
/// File names next to the executable that switch portable mode on.
|
||||
pub markers: &'static [&'static str],
|
||||
/// Folder beside the executable that holds the settings in portable mode.
|
||||
pub dir: &'static str,
|
||||
}
|
||||
|
||||
impl Portable {
|
||||
/// The suite default: a `portable` file puts settings in `config/`.
|
||||
pub const DEFAULT: Self = Self { markers: crate::paths_default::MARKERS, dir: crate::paths_default::DIR };
|
||||
}
|
||||
|
||||
/// Where the application wants its settings.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Location {
|
||||
/// Apply the rules in order.
|
||||
Auto,
|
||||
/// Use this folder, no questions asked (a `--config-dir` flag, or a library folder).
|
||||
Dir(PathBuf),
|
||||
/// Keep settings in memory only; read and write nothing (tests, demos, `<APP>_NO_PREFS`).
|
||||
Memory,
|
||||
}
|
||||
|
||||
/// Which rule chose the folder.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Rule {
|
||||
/// [`Location::Dir`].
|
||||
Override,
|
||||
/// The named environment variable.
|
||||
EnvVar(String),
|
||||
/// The named environment variable was set but empty: isolated, nothing read or written.
|
||||
Isolated(String),
|
||||
/// [`Location::Memory`].
|
||||
Memory,
|
||||
/// A portable marker next to the executable.
|
||||
Portable,
|
||||
/// The platform's config folder.
|
||||
Platform,
|
||||
/// No platform folder could be found; the current directory is used.
|
||||
Fallback,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Rule {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Override => f.write_str("override"),
|
||||
Self::EnvVar(name) => write!(f, "environment variable {name}"),
|
||||
Self::Isolated(name) => write!(f, "{name} is empty: isolated, nothing read or written"),
|
||||
Self::Memory => f.write_str("in memory only"),
|
||||
Self::Portable => f.write_str("portable marker"),
|
||||
Self::Platform => f.write_str("platform default"),
|
||||
Self::Fallback => f.write_str("fallback to current directory"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A chosen folder and the rule behind it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Resolved {
|
||||
/// The configuration folder; `None` for [`Rule::Isolated`] and [`Rule::Memory`].
|
||||
pub dir: Option<PathBuf>,
|
||||
/// Why.
|
||||
pub rule: Rule,
|
||||
}
|
||||
|
||||
/// Reads an environment variable by name.
|
||||
pub type EnvReader = Box<dyn Fn(&str) -> Option<OsString>>;
|
||||
|
||||
/// What the rules look at. The defaults read the real process; tests supply their own.
|
||||
pub struct Probe {
|
||||
/// Reads an environment variable.
|
||||
pub env: EnvReader,
|
||||
/// The folder holding the executable, if known.
|
||||
pub exe_dir: Option<PathBuf>,
|
||||
/// The platform config folder (`~/.config`, `Application Support`, `%APPDATA%`).
|
||||
pub platform_config_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Probe {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
env: Box::new(|name| std::env::var_os(name)),
|
||||
exe_dir: std::env::current_exe().ok().and_then(|p| p.parent().map(Path::to_path_buf)),
|
||||
platform_config_dir: dirs::config_dir(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Probe {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Probe").field("exe_dir", &self.exe_dir).field("platform_config_dir", &self.platform_config_dir).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `<APP>_CONFIG_DIR` variable name for an app id (`photo-craft` → `PHOTO_CRAFT_CONFIG_DIR`).
|
||||
pub fn env_var_name(app_id: &str) -> String {
|
||||
let mut name: String = app_id.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' }).collect();
|
||||
name.push_str("_CONFIG_DIR");
|
||||
name
|
||||
}
|
||||
|
||||
/// Applies the rules for `app_id`, with the app's portable-mode names.
|
||||
pub fn resolve(app_id: &str, portable: &Portable, location: &Location, probe: &Probe) -> Resolved {
|
||||
match location {
|
||||
Location::Dir(dir) => return Resolved { dir: Some(dir.clone()), rule: Rule::Override },
|
||||
Location::Memory => return Resolved { dir: None, rule: Rule::Memory },
|
||||
Location::Auto => {}
|
||||
}
|
||||
let var = env_var_name(app_id);
|
||||
if let Some(value) = (probe.env)(&var) {
|
||||
if value.is_empty() {
|
||||
return Resolved { dir: None, rule: Rule::Isolated(var) };
|
||||
}
|
||||
return Resolved { dir: Some(PathBuf::from(value)), rule: Rule::EnvVar(var) };
|
||||
}
|
||||
if let Some(dir) = probe.exe_dir.as_deref().and_then(|d| portable_dir(d, portable)) {
|
||||
return Resolved { dir: Some(dir), rule: Rule::Portable };
|
||||
}
|
||||
match &probe.platform_config_dir {
|
||||
Some(base) => Resolved { dir: Some(base.join(app_id)), rule: Rule::Platform },
|
||||
None => Resolved { dir: Some(PathBuf::from(".").join(app_id)), rule: Rule::Fallback },
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder a portable marker points at, when `exe_dir` has one.
|
||||
///
|
||||
/// On macOS the executable sits in `X.app/Contents/MacOS`; the marker is looked for next to the
|
||||
/// `.app` bundle, because writing inside a signed bundle breaks its signature.
|
||||
fn portable_dir(exe_dir: &Path, portable: &Portable) -> Option<PathBuf> {
|
||||
let base = bundle_parent(exe_dir).unwrap_or(exe_dir);
|
||||
portable.markers.iter().any(|m| base.join(m).is_file()).then(|| base.join(portable.dir))
|
||||
}
|
||||
|
||||
fn bundle_parent(exe_dir: &Path) -> Option<&Path> {
|
||||
if !cfg!(target_os = "macos") || exe_dir.file_name()? != "MacOS" {
|
||||
return None;
|
||||
}
|
||||
let contents = exe_dir.parent()?;
|
||||
if contents.file_name()? != "Contents" {
|
||||
return None;
|
||||
}
|
||||
let bundle = contents.parent()?;
|
||||
if bundle.extension()? != "app" {
|
||||
return None;
|
||||
}
|
||||
bundle.parent()
|
||||
}
|
||||
|
||||
/// The platform cache folder for `app_id` (`%LOCALAPPDATA%` on Windows, so it does not roam).
|
||||
pub fn cache_dir(app_id: &str) -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|d| d.join(app_id))
|
||||
}
|
||||
|
||||
/// The platform data folder for `app_id`, for recovery files and other non-settings state.
|
||||
pub fn data_dir(app_id: &str) -> Option<PathBuf> {
|
||||
dirs::data_local_dir().map(|d| d.join(app_id))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Keeping damaged and previous files around.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Renames a damaged `config.toml` to `config.broken-<unix-ts>.toml` beside it and returns the
|
||||
/// new path. Never overwrites: a name that exists gets a `-<n>` suffix.
|
||||
pub fn move_aside(path: &Path) -> io::Result<PathBuf> {
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("config");
|
||||
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("toml");
|
||||
let dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let ts = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let mut n = 0u32;
|
||||
loop {
|
||||
let name = if n == 0 { format!("{stem}.broken-{ts}.{ext}") } else { format!("{stem}.broken-{ts}-{n}.{ext}") };
|
||||
let target = dir.join(name);
|
||||
if target.exists() {
|
||||
n = n.saturating_add(1);
|
||||
if n > 1000 {
|
||||
return Err(io::Error::new(io::ErrorKind::AlreadyExists, "too many broken copies"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
std::fs::rename(path, &target)?;
|
||||
return Ok(target);
|
||||
}
|
||||
}
|
||||
|
||||
/// The backup path for a config file: `config.toml` → `config.toml.bak`.
|
||||
pub fn backup_path(path: &Path) -> PathBuf {
|
||||
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
|
||||
name.push(".bak");
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
/// Writes `text`, the last version that parsed, to the backup file atomically.
|
||||
///
|
||||
/// The caller decides what is safe to keep: a blank or unparsable file must never replace the
|
||||
/// previous backup. On Unix the backup always gets the same mode as `path` (`0600` when there is
|
||||
/// none), so it is never readable by more people than the settings themselves.
|
||||
pub fn write_backup(path: &Path, text: &str) -> io::Result<()> {
|
||||
let backup = backup_path(path);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(path).map(|m| m.permissions().mode() & 0o777).unwrap_or(0o600);
|
||||
// An atomic replace keeps the existing file's mode, so narrow an old, broader backup
|
||||
// before new contents go into it, then match the config once written.
|
||||
if backup.exists() {
|
||||
std::fs::set_permissions(&backup, std::fs::Permissions::from_mode(mode))?;
|
||||
}
|
||||
craft_atomic::atomic_write(&backup, text.as_bytes())?;
|
||||
std::fs::set_permissions(&backup, std::fs::Permissions::from_mode(mode))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
craft_atomic::atomic_write(&backup, text.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that `dir` accepts a new file by creating and removing a probe.
|
||||
pub fn check_writable(dir: &Path) -> io::Result<()> {
|
||||
let probe = dir.join(format!(".craft-settings-probe-{}", std::process::id()));
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(&probe)?;
|
||||
std::fs::remove_file(&probe)
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! The in-memory store: load once, read every frame, save while the application runs.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
use crate::Settings;
|
||||
use crate::document::{self, merge_into, set_schema_version};
|
||||
use crate::error::{LoadReport, ReadOnlyReason, SaveStatus, SettingsError, Source, TickResult};
|
||||
use crate::migrate::{self, LegacyFile, Upgrade};
|
||||
use crate::paths::{self, Location, Portable, Probe};
|
||||
use crate::recovery;
|
||||
|
||||
/// The settings file name.
|
||||
pub const CONFIG_FILE: &str = "config.toml";
|
||||
|
||||
/// How long after the last change a save is attempted.
|
||||
pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// First retry delay after a failed save; each failure doubles it, up to [`MAX_RETRY_DELAY`].
|
||||
pub const FIRST_RETRY_DELAY: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Longest wait between retries.
|
||||
pub const MAX_RETRY_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How to open a store.
|
||||
pub struct OpenOptions<T> {
|
||||
/// Where the settings live.
|
||||
pub location: Location,
|
||||
/// Older files to import when there is no `config.toml` yet.
|
||||
pub legacy: Vec<LegacyFile<T>>,
|
||||
/// Quiet period after a change before saving.
|
||||
pub debounce: Duration,
|
||||
/// What the folder rules look at.
|
||||
pub probe: Probe,
|
||||
}
|
||||
|
||||
impl<T> Default for OpenOptions<T> {
|
||||
fn default() -> Self {
|
||||
Self { location: Location::Auto, legacy: Vec::new(), debounce: DEFAULT_DEBOUNCE, probe: Probe::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> OpenOptions<T> {
|
||||
/// Options for `location`.
|
||||
pub fn new(location: Location) -> Self {
|
||||
Self { location, ..Self::default() }
|
||||
}
|
||||
|
||||
/// Older files to import on first run.
|
||||
pub fn legacy(mut self, files: Vec<LegacyFile<T>>) -> Self {
|
||||
self.legacy = files;
|
||||
self
|
||||
}
|
||||
|
||||
/// Quiet period after a change before saving.
|
||||
pub fn debounce(mut self, debounce: Duration) -> Self {
|
||||
self.debounce = debounce;
|
||||
self
|
||||
}
|
||||
|
||||
/// What the folder rules look at.
|
||||
pub fn probe(mut self, probe: Probe) -> Self {
|
||||
self.probe = probe;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// An application's settings, with the file they came from.
|
||||
///
|
||||
/// With no folder (an empty `<APP>_CONFIG_DIR`, or [`Location::Memory`]) the store keeps
|
||||
/// everything in memory: `get`/`update` work as usual, nothing is read or written.
|
||||
pub struct SettingsStore<T: Settings> {
|
||||
dir: Option<PathBuf>,
|
||||
path: Option<PathBuf>,
|
||||
doc: DocumentMut,
|
||||
value: T,
|
||||
read_only: Option<String>,
|
||||
dirty: bool,
|
||||
written: bool,
|
||||
last_change: Option<Instant>,
|
||||
debounce: Duration,
|
||||
failures: u32,
|
||||
retry_at: Option<Instant>,
|
||||
/// The file's contents as last read or written. Compared by content, not mtime, so an edit
|
||||
/// within the same second (coarse timestamps on FAT, HFS+, network shares) is still seen.
|
||||
seen_text: Option<String>,
|
||||
/// Files that were damaged on disk at save time and moved aside; see [`SettingsStore::take_moved_aside`].
|
||||
moved_aside: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl<T: Settings> std::fmt::Debug for SettingsStore<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SettingsStore")
|
||||
.field("path", &self.path)
|
||||
.field("read_only", &self.read_only)
|
||||
.field("dirty", &self.dirty)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Settings> SettingsStore<T> {
|
||||
/// Opens the store for `location`. Never fails: problems come back in the report.
|
||||
pub fn open(location: Location) -> (Self, LoadReport) {
|
||||
Self::open_with(OpenOptions::new(location))
|
||||
}
|
||||
|
||||
/// Opens a store in `dir`, for settings that travel with a folder such as a library.
|
||||
pub fn open_at(dir: impl Into<PathBuf>) -> (Self, LoadReport) {
|
||||
Self::open(Location::Dir(dir.into()))
|
||||
}
|
||||
|
||||
/// Opens the store with full options.
|
||||
pub fn open_with(options: OpenOptions<T>) -> (Self, LoadReport) {
|
||||
let portable = Portable { markers: T::PORTABLE_MARKERS, dir: T::PORTABLE_DIR };
|
||||
let resolved = paths::resolve(T::APP_ID, &portable, &options.location, &options.probe);
|
||||
let path = resolved.dir.as_ref().map(|d| d.join(CONFIG_FILE));
|
||||
let mut report = LoadReport {
|
||||
dir: resolved.dir.clone(),
|
||||
rule: resolved.rule.to_string(),
|
||||
source: Source::Defaults,
|
||||
broken_moved_to: None,
|
||||
section_errors: Vec::new(),
|
||||
migrated_from: None,
|
||||
read_only: None,
|
||||
};
|
||||
let mut store = Self {
|
||||
dir: resolved.dir,
|
||||
path,
|
||||
doc: document::new_document(T::SCHEMA_VERSION),
|
||||
value: T::default(),
|
||||
read_only: None,
|
||||
dirty: false,
|
||||
written: false,
|
||||
last_change: None,
|
||||
debounce: options.debounce,
|
||||
failures: 0,
|
||||
retry_at: None,
|
||||
seen_text: None,
|
||||
moved_aside: Vec::new(),
|
||||
};
|
||||
|
||||
let (Some(dir), Some(path)) = (store.dir.clone(), store.path.clone()) else {
|
||||
return (store, report);
|
||||
};
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
store.load_best_effort(&mut report);
|
||||
store.set_read_only(&mut report, ReadOnlyReason::DirUnavailable(e));
|
||||
return (store, report);
|
||||
}
|
||||
if let Err(e) = recovery::check_writable(&dir) {
|
||||
store.load_best_effort(&mut report);
|
||||
store.set_read_only(&mut report, ReadOnlyReason::DirNotWritable(e));
|
||||
return (store, report);
|
||||
}
|
||||
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => store.load_text(&path, &text, &mut report),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
if let Some((value, from)) = migrate::import_legacy(&options.legacy) {
|
||||
store.value = value;
|
||||
store.dirty = true;
|
||||
report.source = Source::Legacy(from);
|
||||
}
|
||||
}
|
||||
Err(e) => store.set_read_only(&mut report, ReadOnlyReason::Unreadable(e)),
|
||||
}
|
||||
(store, report)
|
||||
}
|
||||
|
||||
/// The configuration folder; `None` when the store is in memory only.
|
||||
pub fn dir(&self) -> Option<&Path> {
|
||||
self.dir.as_deref()
|
||||
}
|
||||
|
||||
/// The `config.toml` path; `None` when the store is in memory only.
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
|
||||
/// True when the store reads and writes a file; false when it is in memory only.
|
||||
pub fn is_persistent(&self) -> bool {
|
||||
self.path.is_some()
|
||||
}
|
||||
|
||||
/// The current settings. In memory; cheap to call every frame.
|
||||
pub fn get(&self) -> &T {
|
||||
&self.value
|
||||
}
|
||||
|
||||
/// True when the store will not write the file; the load report says why.
|
||||
pub fn is_read_only(&self) -> bool {
|
||||
self.read_only.is_some()
|
||||
}
|
||||
|
||||
/// Damaged `config.toml` files found and moved aside while saving (someone broke the file
|
||||
/// while the app was running). Drains the list; show these to the user like
|
||||
/// [`LoadReport::broken_moved_to`].
|
||||
pub fn take_moved_aside(&mut self) -> Vec<PathBuf> {
|
||||
std::mem::take(&mut self.moved_aside)
|
||||
}
|
||||
|
||||
/// True when changes are waiting to be saved.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
|
||||
/// Changes the settings. Returns true, and schedules a save, only if the value changed.
|
||||
pub fn update(&mut self, f: impl FnOnce(&mut T)) -> bool {
|
||||
let mut next = self.value.clone();
|
||||
f(&mut next);
|
||||
self.set(next)
|
||||
}
|
||||
|
||||
/// Replaces the settings. Returns true, and schedules a save, only if the value changed.
|
||||
pub fn set(&mut self, value: T) -> bool {
|
||||
if value == self.value {
|
||||
return false;
|
||||
}
|
||||
self.value = value;
|
||||
self.mark_changed(Instant::now());
|
||||
true
|
||||
}
|
||||
|
||||
/// Like [`SettingsStore::update`] with an explicit clock, for tests.
|
||||
pub fn update_at(&mut self, now: Instant, f: impl FnOnce(&mut T)) -> bool {
|
||||
let mut next = self.value.clone();
|
||||
f(&mut next);
|
||||
if next == self.value {
|
||||
return false;
|
||||
}
|
||||
self.value = next;
|
||||
self.mark_changed(now);
|
||||
true
|
||||
}
|
||||
|
||||
/// Saves pending changes once the debounce or retry delay has passed. Call every frame
|
||||
/// and again at `next_deadline`, so an idle application still saves.
|
||||
pub fn tick(&mut self, now: Instant) -> TickResult {
|
||||
if !self.dirty || self.read_only.is_some() {
|
||||
return TickResult { status: SaveStatus::Idle, next_deadline: None };
|
||||
}
|
||||
let due = match (self.retry_at, self.last_change) {
|
||||
(Some(retry), _) => retry,
|
||||
(None, Some(changed)) => changed + self.debounce,
|
||||
(None, None) => now,
|
||||
};
|
||||
if now < due {
|
||||
return TickResult { status: SaveStatus::Pending, next_deadline: Some(due) };
|
||||
}
|
||||
match self.save() {
|
||||
Ok(()) => TickResult { status: SaveStatus::Saved, next_deadline: None },
|
||||
Err(e) => {
|
||||
self.failures = self.failures.saturating_add(1);
|
||||
let delay = retry_delay(self.failures);
|
||||
self.retry_at = Some(now + delay);
|
||||
TickResult { status: SaveStatus::Failed(e), next_deadline: Some(now + delay) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves now. Writes the file even when nothing changed if it does not exist yet, so users
|
||||
/// can find it. Call on exit.
|
||||
pub fn flush(&mut self) -> Result<(), SettingsError> {
|
||||
if let Some(why) = &self.read_only {
|
||||
return Err(SettingsError::ReadOnly(why.clone()));
|
||||
}
|
||||
let Some(path) = &self.path else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self.dirty && (self.written || path.exists()) {
|
||||
return Ok(());
|
||||
}
|
||||
self.save()
|
||||
}
|
||||
|
||||
fn mark_changed(&mut self, now: Instant) {
|
||||
// An in-memory store has nothing to save, so it never becomes dirty.
|
||||
self.dirty = self.path.is_some();
|
||||
self.last_change = Some(now);
|
||||
}
|
||||
|
||||
fn set_read_only(&mut self, report: &mut LoadReport, reason: ReadOnlyReason) {
|
||||
self.read_only = Some(reason.to_string());
|
||||
report.read_only = Some(reason);
|
||||
}
|
||||
|
||||
/// Reads the file if it happens to be readable, so a read-only store still shows the
|
||||
/// user's values instead of defaults.
|
||||
fn load_best_effort(&mut self, report: &mut LoadReport) {
|
||||
if let Some(path) = &self.path
|
||||
&& let Ok(text) = std::fs::read_to_string(path)
|
||||
&& let Ok(mut doc) = document::parse(&text)
|
||||
{
|
||||
// A failed migration leaves the document half-upgraded; show defaults instead.
|
||||
let Ok(upgraded) = migrate::upgrade::<T>(&mut doc) else {
|
||||
return;
|
||||
};
|
||||
let (value, errors) = document::deserialize_sections::<T>(&doc);
|
||||
self.value = value;
|
||||
self.doc = doc;
|
||||
self.seen_text = Some(text);
|
||||
report.source = Source::File;
|
||||
report.section_errors = errors;
|
||||
if let Upgrade::Migrated { from } = upgraded {
|
||||
report.migrated_from = Some(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_text(&mut self, path: &Path, text: &str, report: &mut LoadReport) {
|
||||
let mut doc = match document::parse(text) {
|
||||
Ok(doc) => doc,
|
||||
Err(_) => {
|
||||
match recovery::move_aside(path) {
|
||||
Ok(moved) => report.broken_moved_to = Some(moved),
|
||||
Err(e) => self.set_read_only(report, ReadOnlyReason::CannotMoveAside(e)),
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
match migrate::upgrade::<T>(&mut doc) {
|
||||
Ok(Upgrade::Current) => {}
|
||||
Ok(Upgrade::Migrated { from }) => {
|
||||
report.migrated_from = Some(from);
|
||||
self.dirty = true;
|
||||
}
|
||||
Ok(Upgrade::Newer { file }) => self.set_read_only(report, ReadOnlyReason::NewerSchema { file, app: T::SCHEMA_VERSION }),
|
||||
Err(e) => {
|
||||
self.set_read_only(report, ReadOnlyReason::Migration(e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
let (value, errors) = document::deserialize_sections::<T>(&doc);
|
||||
self.value = value;
|
||||
self.doc = doc;
|
||||
self.seen_text = Some(text.to_owned());
|
||||
report.source = Source::File;
|
||||
report.section_errors = errors;
|
||||
}
|
||||
|
||||
fn save(&mut self) -> Result<(), SettingsError> {
|
||||
if let Some(why) = &self.read_only {
|
||||
return Err(SettingsError::ReadOnly(why.clone()));
|
||||
}
|
||||
let Some(path) = self.path.clone() else {
|
||||
self.dirty = false;
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(on_disk) = self.check_disk(&path)? {
|
||||
recovery::write_backup(&path, &on_disk)?;
|
||||
}
|
||||
merge_into(&mut self.doc, &self.value).map_err(|e| SettingsError::Serialize(e.to_string()))?;
|
||||
set_schema_version(&mut self.doc, T::SCHEMA_VERSION);
|
||||
let text = document::render(&self.doc);
|
||||
craft_atomic::atomic_write_with(&path, |file| {
|
||||
restrict_permissions(file)?;
|
||||
file.write_all(text.as_bytes())
|
||||
})?;
|
||||
self.seen_text = Some(text);
|
||||
self.dirty = false;
|
||||
self.written = true;
|
||||
self.failures = 0;
|
||||
self.retry_at = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Looks at the file on disk just before a save, and returns its text when it is a good
|
||||
/// backup (it parses and is not blank).
|
||||
///
|
||||
/// The contract is best effort, not multi-process locking: if another instance or a hand
|
||||
/// edit changed the file since this store last read or wrote it, the new document is adopted
|
||||
/// so its comments and unknown keys survive, and this store's values then win on known keys.
|
||||
/// A file that no longer parses is moved aside (the edit is kept, the previous backup stays),
|
||||
/// and a file from a newer build, or one whose migration fails, makes the store read-only
|
||||
/// instead of overwriting it, the same as at startup.
|
||||
fn check_disk(&mut self, path: &Path) -> Result<Option<String>, SettingsError> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let Ok(mut doc) = document::parse(&text) else {
|
||||
self.moved_aside.push(recovery::move_aside(path)?);
|
||||
return Ok(None);
|
||||
};
|
||||
if self.seen_text.as_deref() != Some(text.as_str()) {
|
||||
match migrate::upgrade::<T>(&mut doc) {
|
||||
Ok(Upgrade::Newer { file }) => {
|
||||
let why = ReadOnlyReason::NewerSchema { file, app: T::SCHEMA_VERSION }.to_string();
|
||||
self.read_only = Some(why.clone());
|
||||
return Err(SettingsError::ReadOnly(why));
|
||||
}
|
||||
Ok(_) => self.doc = doc,
|
||||
// Same as at startup: leave the outside file untouched and stop saving.
|
||||
Err(e) => {
|
||||
let why = ReadOnlyReason::Migration(e).to_string();
|
||||
self.read_only = Some(why.clone());
|
||||
return Err(SettingsError::ReadOnly(why));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((!text.trim().is_empty()).then_some(text))
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponential backoff: 1 s, 2 s, 4 s, … capped at [`MAX_RETRY_DELAY`].
|
||||
pub fn retry_delay(failures: u32) -> Duration {
|
||||
let exp = failures.saturating_sub(1).min(16);
|
||||
(FIRST_RETRY_DELAY * 2u32.saturating_pow(exp)).min(MAX_RETRY_DELAY)
|
||||
}
|
||||
|
||||
/// New files are owner-only; on a replace `craft-atomic` keeps the existing file's mode.
|
||||
#[cfg(unix)]
|
||||
fn restrict_permissions(file: &std::fs::File) -> io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn restrict_permissions(_file: &std::fs::File) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use craft_settings::{DocumentMut, Location, MigrateError, OpenOptions, Probe, Settings, SettingsStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct Prefs {
|
||||
pub general: General,
|
||||
pub appearance: Appearance,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct General {
|
||||
pub language: String,
|
||||
pub autosave_minutes: u32,
|
||||
pub recent: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for General {
|
||||
fn default() -> Self {
|
||||
Self { language: "en".into(), autosave_minutes: 5, recent: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct Appearance {
|
||||
pub theme: Theme,
|
||||
pub ui_scale: f64,
|
||||
}
|
||||
|
||||
impl Default for Appearance {
|
||||
fn default() -> Self {
|
||||
Self { theme: Theme::Light, ui_scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Theme {
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl Settings for Prefs {
|
||||
const APP_ID: &'static str = "testcraft";
|
||||
const SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
/// Version 1 stored the theme as `[appearance] dark = true`.
|
||||
fn migrate(from: u32, doc: &mut DocumentMut) -> Result<(), MigrateError> {
|
||||
match from {
|
||||
1 => {
|
||||
let dark = doc.get("appearance").and_then(|a| a.get("dark")).and_then(|d| d.as_bool());
|
||||
if let Some(dark) = dark {
|
||||
doc["appearance"]["theme"] = toml_edit::value(if dark { "dark" } else { "light" });
|
||||
if let Some(table) = doc["appearance"].as_table_like_mut() {
|
||||
table.remove("dark");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
other => Err(MigrateError::new(other, "unknown version")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A probe that sees no environment, no executable and no platform folder.
|
||||
pub fn empty_probe() -> Probe {
|
||||
Probe { env: Box::new(|_| None), exe_dir: None, platform_config_dir: None }
|
||||
}
|
||||
|
||||
pub fn probe_with_env(name: &'static str, value: &'static str) -> Probe {
|
||||
Probe { env: Box::new(move |n| (n == name).then(|| OsString::from(value))), exe_dir: None, platform_config_dir: None }
|
||||
}
|
||||
|
||||
pub fn open_in(dir: &Path) -> (SettingsStore<Prefs>, craft_settings::LoadReport) {
|
||||
SettingsStore::open_with(OpenOptions::new(Location::Dir(dir.to_path_buf())).probe(empty_probe()).debounce(Duration::from_millis(100)))
|
||||
}
|
||||
|
||||
pub fn config_path(dir: &Path) -> PathBuf {
|
||||
dir.join("config.toml")
|
||||
}
|
||||
|
||||
pub fn write_config(dir: &Path, text: &str) {
|
||||
std::fs::write(config_path(dir), text).unwrap();
|
||||
}
|
||||
|
||||
pub fn read_config(dir: &Path) -> String {
|
||||
std::fs::read_to_string(config_path(dir)).unwrap()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
mod common;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use common::{Prefs, Theme, empty_probe, probe_with_env};
|
||||
use craft_settings::{Location, OpenOptions, SaveStatus, SettingsStore, Source};
|
||||
|
||||
/// A probe whose platform folder is `dir`, so any write would land there.
|
||||
fn probe_in(dir: &std::path::Path, env: Option<(&'static str, &'static str)>) -> craft_settings::Probe {
|
||||
let mut probe = match env {
|
||||
Some((name, value)) => probe_with_env(name, value),
|
||||
None => empty_probe(),
|
||||
};
|
||||
probe.platform_config_dir = Some(dir.to_path_buf());
|
||||
probe
|
||||
}
|
||||
|
||||
fn assert_untouched(dir: &std::path::Path) {
|
||||
let entries: Vec<_> = std::fs::read_dir(dir).unwrap().collect();
|
||||
assert!(entries.is_empty(), "nothing may be written: {entries:?}");
|
||||
}
|
||||
|
||||
fn exercise(store: &mut SettingsStore<Prefs>) {
|
||||
assert!(!store.is_persistent());
|
||||
assert_eq!(store.dir(), None);
|
||||
assert_eq!(store.path(), None);
|
||||
assert!(store.update(|p| p.appearance.theme = Theme::Dark), "updates still apply");
|
||||
assert_eq!(store.get().appearance.theme, Theme::Dark);
|
||||
assert!(!store.is_dirty(), "nothing to save");
|
||||
assert!(matches!(store.tick(Instant::now() + Duration::from_secs(5)).status, SaveStatus::Idle));
|
||||
store.flush().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_env_var_reads_and_writes_nothing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// A real settings file in the platform folder must not be read either.
|
||||
std::fs::create_dir(tmp.path().join("testcraft")).unwrap();
|
||||
std::fs::write(tmp.path().join("testcraft/config.toml"), "schema_version = 2\n[general]\nlanguage = \"de\"\n").unwrap();
|
||||
|
||||
let (mut store, report) =
|
||||
SettingsStore::<Prefs>::open_with(OpenOptions::new(Location::Auto).probe(probe_in(tmp.path(), Some(("TESTCRAFT_CONFIG_DIR", "")))));
|
||||
assert_eq!(report.dir, None);
|
||||
assert_eq!(report.source, Source::Defaults);
|
||||
assert!(report.rule.contains("isolated"), "{}", report.rule);
|
||||
assert!(!report.has_problems(), "{report:?}");
|
||||
assert_eq!(store.get().general.language, "en", "the real profile is not read");
|
||||
exercise(&mut store);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(tmp.path().join("testcraft/config.toml")).unwrap(),
|
||||
"schema_version = 2\n[general]\nlanguage = \"de\"\n",
|
||||
"the real profile is not written"
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(tmp.path().join("testcraft")).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_location_reads_and_writes_nothing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, report) = SettingsStore::<Prefs>::open_with(OpenOptions::new(Location::Memory).probe(probe_in(tmp.path(), None)));
|
||||
assert_eq!(report.rule, "in memory only");
|
||||
exercise(&mut store);
|
||||
assert_untouched(tmp.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_ignores_legacy_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("prefs.json");
|
||||
std::fs::write(&old, "{}").unwrap();
|
||||
let files = vec![craft_settings::LegacyFile { path: old, parse: |_| Ok(Prefs::default()) }];
|
||||
let (_, report) = SettingsStore::<Prefs>::open_with(OpenOptions::new(Location::Memory).probe(empty_probe()).legacy(files));
|
||||
assert_eq!(report.source, Source::Defaults, "an isolated store must not import from the real profile");
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
mod common;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{Prefs, Theme, empty_probe, open_in, read_config, write_config};
|
||||
use craft_settings::{LegacyFile, Location, OpenOptions, SettingsStore, Source};
|
||||
|
||||
#[test]
|
||||
fn older_schema_is_migrated_step_by_step_and_saved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_config(tmp.path(), "schema_version = 1\n\n# keep me\n[appearance]\ndark = true\nui_scale = 1.25\n");
|
||||
let (mut store, report) = open_in(tmp.path());
|
||||
assert_eq!(report.migrated_from, Some(1), "{report:?}");
|
||||
assert!(report.section_errors.is_empty(), "{report:?}");
|
||||
assert_eq!(store.get().appearance.theme, Theme::Dark);
|
||||
assert_eq!(store.get().appearance.ui_scale, 1.25);
|
||||
assert!(store.is_dirty(), "a migrated file is written back");
|
||||
|
||||
store.flush().unwrap();
|
||||
let text = read_config(tmp.path());
|
||||
assert!(text.starts_with("schema_version = 2\n"), "{text}");
|
||||
assert!(text.contains("# keep me"), "{text}");
|
||||
assert!(text.contains("theme = \"dark\""), "{text}");
|
||||
assert!(!text.contains("dark = true"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_schema_version_is_treated_as_current() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_config(tmp.path(), "[general]\nlanguage = \"pt\"\n");
|
||||
let (mut store, report) = open_in(tmp.path());
|
||||
assert_eq!(report.migrated_from, None);
|
||||
assert!(!store.is_read_only(), "{report:?}");
|
||||
assert_eq!(store.get().general.language, "pt");
|
||||
store.update(|p| p.general.autosave_minutes = 9);
|
||||
store.flush().unwrap();
|
||||
assert!(read_config(tmp.path()).starts_with("schema_version = 2\n"));
|
||||
}
|
||||
|
||||
fn parse_legacy_json(bytes: &[u8]) -> Result<Prefs, String> {
|
||||
// Stand-in for an app's real JSON parser: `{"lang":"de","dark":true}`.
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
|
||||
let mut prefs = Prefs::default();
|
||||
if text.contains("\"dark\":true") {
|
||||
prefs.appearance.theme = Theme::Dark;
|
||||
}
|
||||
if let Some(start) = text.find("\"lang\":\"") {
|
||||
let rest = &text[start + 8..];
|
||||
prefs.general.language = rest[..rest.find('"').ok_or("unterminated")?].to_owned();
|
||||
}
|
||||
Ok(prefs)
|
||||
}
|
||||
|
||||
fn open_with_legacy(dir: &std::path::Path, files: Vec<LegacyFile<Prefs>>) -> (SettingsStore<Prefs>, craft_settings::LoadReport) {
|
||||
SettingsStore::open_with(
|
||||
OpenOptions::new(Location::Dir(dir.to_path_buf())).probe(empty_probe()).debounce(Duration::from_millis(100)).legacy(files),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_file_is_imported_on_first_run_and_left_in_place() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("prefs.json");
|
||||
std::fs::write(&old, r#"{"lang":"de","dark":true}"#).unwrap();
|
||||
|
||||
let (mut store, report) = open_with_legacy(tmp.path(), vec![LegacyFile { path: old.clone(), parse: parse_legacy_json }]);
|
||||
assert_eq!(report.source, Source::Legacy(old.clone()));
|
||||
assert_eq!(store.get().general.language, "de");
|
||||
assert_eq!(store.get().appearance.theme, Theme::Dark);
|
||||
assert!(store.is_dirty());
|
||||
store.flush().unwrap();
|
||||
|
||||
assert!(old.exists(), "the old file is copied, not removed, so a rollback still works");
|
||||
assert_eq!(std::fs::read_to_string(&old).unwrap(), r#"{"lang":"de","dark":true}"#);
|
||||
assert!(read_config(tmp.path()).contains("language = \"de\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_import_is_skipped_once_config_exists() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("prefs.json");
|
||||
std::fs::write(&old, r#"{"lang":"de"}"#).unwrap();
|
||||
write_config(tmp.path(), "schema_version = 2\n[general]\nlanguage = \"it\"\n");
|
||||
let (store, report) = open_with_legacy(tmp.path(), vec![LegacyFile { path: old, parse: parse_legacy_json }]);
|
||||
assert_eq!(report.source, Source::File);
|
||||
assert_eq!(store.get().general.language, "it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_legacy_file_that_parses_wins() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let missing = tmp.path().join("nope.json");
|
||||
let bad = tmp.path().join("bad.json");
|
||||
std::fs::write(&bad, [0xff, 0xfe]).unwrap();
|
||||
let good = tmp.path().join("good.json");
|
||||
std::fs::write(&good, r#"{"lang":"nl"}"#).unwrap();
|
||||
let files = vec![
|
||||
LegacyFile { path: missing, parse: parse_legacy_json },
|
||||
LegacyFile { path: bad, parse: parse_legacy_json },
|
||||
LegacyFile { path: good.clone(), parse: parse_legacy_json },
|
||||
];
|
||||
let (store, report) = open_with_legacy(tmp.path(), files);
|
||||
assert_eq!(report.source, Source::Legacy(good));
|
||||
assert_eq!(store.get().general.language, "nl");
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
mod common;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use common::{Prefs, empty_probe, open_in, probe_with_env};
|
||||
use craft_settings::paths::{Location, Portable, Probe, Rule, env_var_name, resolve};
|
||||
use craft_settings::{OpenOptions, Settings, SettingsStore};
|
||||
|
||||
#[test]
|
||||
fn env_var_name_uppercases_and_replaces_punctuation() {
|
||||
assert_eq!(env_var_name("photocraft"), "PHOTOCRAFT_CONFIG_DIR");
|
||||
assert_eq!(env_var_name("photo-craft"), "PHOTO_CRAFT_CONFIG_DIR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_wins_over_everything() {
|
||||
let probe = probe_with_env("TESTCRAFT_CONFIG_DIR", "/from/env");
|
||||
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Dir(PathBuf::from("/explicit")), &probe);
|
||||
assert_eq!(r.dir, Some(PathBuf::from("/explicit")));
|
||||
assert_eq!(r.rule, Rule::Override);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_var_wins_over_portable_and_platform() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("portable"), "").unwrap();
|
||||
let mut probe = probe_with_env("TESTCRAFT_CONFIG_DIR", "/from/env");
|
||||
probe.exe_dir = Some(tmp.path().to_path_buf());
|
||||
probe.platform_config_dir = Some(PathBuf::from("/platform"));
|
||||
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
|
||||
assert_eq!(r.dir, Some(PathBuf::from("/from/env")));
|
||||
assert_eq!(r.rule, Rule::EnvVar("TESTCRAFT_CONFIG_DIR".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_env_var_means_isolated_not_unset() {
|
||||
let mut probe = probe_with_env("TESTCRAFT_CONFIG_DIR", "");
|
||||
probe.platform_config_dir = Some(PathBuf::from("/platform"));
|
||||
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
|
||||
assert_eq!(r.rule, Rule::Isolated("TESTCRAFT_CONFIG_DIR".into()));
|
||||
assert_eq!(r.dir, None, "never falls through to the real profile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_location_has_no_folder() {
|
||||
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Memory, &probe_with_env("TESTCRAFT_CONFIG_DIR", "/from/env"));
|
||||
assert_eq!(r.rule, Rule::Memory);
|
||||
assert_eq!(r.dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_supplied_portable_names_are_used() {
|
||||
const LEGACY: Portable = Portable { markers: &["portable.txt", "PhotoCraft.portable"], dir: "PhotoCraftData" };
|
||||
for marker in ["portable.txt", "PhotoCraft.portable"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join(marker), "").unwrap();
|
||||
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(tmp.path().to_path_buf()), platform_config_dir: Some(PathBuf::from("/platform")) };
|
||||
let r = resolve("testcraft", &LEGACY, &Location::Auto, &probe);
|
||||
assert_eq!(r.rule, Rule::Portable, "{marker}");
|
||||
assert_eq!(r.dir, Some(tmp.path().join("PhotoCraftData")), "{marker}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_marker_is_ignored_when_the_app_uses_its_own() {
|
||||
const LEGACY: Portable = Portable { markers: &["portable.txt"], dir: "PhotoCraftData" };
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("portable"), "").unwrap();
|
||||
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(tmp.path().to_path_buf()), platform_config_dir: Some(PathBuf::from("/platform")) };
|
||||
assert_eq!(resolve("testcraft", &LEGACY, &Location::Auto, &probe).rule, Rule::Platform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_marker_next_to_executable_uses_config_folder_beside_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("portable"), "").unwrap();
|
||||
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(tmp.path().to_path_buf()), platform_config_dir: Some(PathBuf::from("/platform")) };
|
||||
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
|
||||
assert_eq!(r.rule, Rule::Portable);
|
||||
assert_eq!(r.dir, Some(tmp.path().join("config")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_means_platform_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(tmp.path().to_path_buf()), platform_config_dir: Some(PathBuf::from("/platform")) };
|
||||
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
|
||||
assert_eq!(r.rule, Rule::Platform);
|
||||
assert_eq!(r.dir, Some(PathBuf::from("/platform/testcraft")));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn portable_marker_is_looked_for_next_to_the_app_bundle() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let exe_dir = tmp.path().join("Test.app/Contents/MacOS");
|
||||
std::fs::create_dir_all(&exe_dir).unwrap();
|
||||
std::fs::write(tmp.path().join("portable"), "").unwrap();
|
||||
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(exe_dir), platform_config_dir: None };
|
||||
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
|
||||
assert_eq!(r.rule, Rule::Portable);
|
||||
assert_eq!(r.dir, Some(tmp.path().join("config")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_platform_folder_falls_back_to_current_directory() {
|
||||
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &empty_probe());
|
||||
assert_eq!(r.rule, Rule::Fallback);
|
||||
assert_eq!(r.dir, Some(PathBuf::from("./testcraft")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_names_the_rule_and_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_store, report) = open_in(tmp.path());
|
||||
assert_eq!(report.dir.as_deref(), Some(tmp.path()));
|
||||
assert_eq!(report.rule, "override");
|
||||
assert!(!report.has_problems(), "{report:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_creates_the_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("nested").join(Prefs::APP_ID);
|
||||
let (store, report) = SettingsStore::<Prefs>::open_with(OpenOptions::new(Location::Dir(dir.clone())).probe(empty_probe()));
|
||||
assert!(dir.is_dir());
|
||||
assert!(!store.is_read_only(), "{report:?}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unwritable_folder_is_reported_at_open_not_first_save() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("ro");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
let (mut store, report) = open_in(&dir);
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(store.is_read_only());
|
||||
assert!(matches!(report.read_only, Some(craft_settings::ReadOnlyReason::DirNotWritable(_))), "{report:?}");
|
||||
assert!(matches!(store.flush(), Err(craft_settings::SettingsError::ReadOnly(_))));
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
mod common;
|
||||
|
||||
use common::{Theme, config_path, open_in, read_config, write_config};
|
||||
use craft_settings::{ReadOnlyReason, SettingsError, Source};
|
||||
|
||||
#[test]
|
||||
fn syntax_error_moves_the_file_aside_and_uses_defaults() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_config(tmp.path(), "schema_version = 2\n[general\nlanguage = \"de\"\n");
|
||||
let (mut store, report) = open_in(tmp.path());
|
||||
assert_eq!(report.source, Source::Defaults);
|
||||
assert!(!store.is_read_only());
|
||||
let moved = report.broken_moved_to.clone().expect("moved aside");
|
||||
assert!(moved.file_name().unwrap().to_str().unwrap().starts_with("config.broken-"), "{moved:?}");
|
||||
assert!(moved.exists());
|
||||
assert!(!config_path(tmp.path()).exists());
|
||||
assert_eq!(store.get().general.language, "en");
|
||||
|
||||
store.flush().unwrap();
|
||||
assert!(moved.exists(), "the damaged copy is never deleted");
|
||||
assert!(read_config(tmp.path()).starts_with("schema_version = 2\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_broken_files_get_different_names() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_config(tmp.path(), "not = [toml");
|
||||
let (_, first) = open_in(tmp.path());
|
||||
write_config(tmp.path(), "still = [broken");
|
||||
let (_, second) = open_in(tmp.path());
|
||||
assert_ne!(first.broken_moved_to, second.broken_moved_to);
|
||||
assert!(second.broken_moved_to.unwrap().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_value_only_resets_its_section() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_config(tmp.path(), "schema_version = 2\n[general]\nlanguage = \"de\"\n\n[appearance]\ntheme = \"sepia\"\nui_scale = 2.0\n");
|
||||
let (store, report) = open_in(tmp.path());
|
||||
assert_eq!(report.source, Source::File);
|
||||
assert!(report.broken_moved_to.is_none());
|
||||
assert!(!store.is_read_only());
|
||||
assert_eq!(report.section_errors.len(), 1, "{report:?}");
|
||||
assert_eq!(report.section_errors[0].section, "appearance");
|
||||
assert_eq!(store.get().general.language, "de", "the good section survives");
|
||||
assert_eq!(store.get().appearance.theme, Theme::Light, "the bad section falls back to defaults");
|
||||
assert!(config_path(tmp.path()).exists(), "the file is not moved aside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_schema_opens_read_only_and_never_overwrites() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let original = "schema_version = 9\n[general]\nlanguage = \"de\"\n";
|
||||
write_config(tmp.path(), original);
|
||||
let (mut store, report) = open_in(tmp.path());
|
||||
assert!(matches!(report.read_only, Some(ReadOnlyReason::NewerSchema { file: 9, app: 2 })), "{report:?}");
|
||||
assert_eq!(store.get().general.language, "de", "values are still shown");
|
||||
store.update(|p| p.general.language = "fr".into());
|
||||
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
|
||||
assert_eq!(read_config(tmp.path()), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_migration_opens_read_only_and_leaves_the_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let original = "schema_version = 0\n[general]\nlanguage = \"de\"\n";
|
||||
write_config(tmp.path(), original);
|
||||
let (mut store, report) = open_in(tmp.path());
|
||||
assert!(matches!(report.read_only, Some(ReadOnlyReason::Migration(_))), "{report:?}");
|
||||
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
|
||||
assert_eq!(read_config(tmp.path()), original);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn damaged_file_that_cannot_be_moved_is_left_alone() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_config(tmp.path(), "not = [toml");
|
||||
// The folder passes the write probe only while it is writable, so open first, then lock it.
|
||||
let dir = tmp.path();
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
let (mut store, report) = open_in(dir);
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(store.is_read_only(), "{report:?}");
|
||||
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
|
||||
assert_eq!(read_config(dir), "not = [toml");
|
||||
}
|
||||
|
||||
// --- Review on #22: the backup must never be replaced by a damaged or emptied file. ---
|
||||
|
||||
#[test]
|
||||
fn file_damaged_while_running_is_moved_aside_and_backup_kept() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.update(|p| p.general.language = "fr".into());
|
||||
store.flush().unwrap();
|
||||
store.update(|p| p.general.language = "es".into());
|
||||
store.flush().unwrap();
|
||||
let good_backup = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
|
||||
assert!(good_backup.contains("language = \"fr\""));
|
||||
|
||||
// A hand edit with a typo while the app is running.
|
||||
write_config(tmp.path(), "schema_version = 2\n[general\nlanguage = \"it\"\n");
|
||||
store.update(|p| p.general.language = "pt".into());
|
||||
store.flush().unwrap();
|
||||
|
||||
assert_eq!(std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap(), good_backup, "backup untouched");
|
||||
let moved = store.take_moved_aside();
|
||||
assert_eq!(moved.len(), 1);
|
||||
assert_eq!(std::fs::read_to_string(&moved[0]).unwrap(), "schema_version = 2\n[general\nlanguage = \"it\"\n", "the user's edit is kept");
|
||||
assert!(read_config(tmp.path()).contains("language = \"pt\""));
|
||||
assert!(store.take_moved_aside().is_empty(), "drained");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emptied_file_does_not_replace_the_backup() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.update(|p| p.general.language = "fr".into());
|
||||
store.flush().unwrap();
|
||||
store.update(|p| p.general.language = "es".into());
|
||||
store.flush().unwrap();
|
||||
let good_backup = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
|
||||
|
||||
write_config(tmp.path(), "");
|
||||
store.update(|p| p.general.language = "pt".into());
|
||||
store.flush().unwrap();
|
||||
assert_eq!(std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap(), good_backup);
|
||||
assert!(read_config(tmp.path()).contains("language = \"pt\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_build_writing_while_running_is_not_overwritten() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.flush().unwrap();
|
||||
let newer = "schema_version = 9\n[general]\nlanguage = \"de\"\n";
|
||||
write_config(tmp.path(), newer);
|
||||
store.update(|p| p.general.language = "fr".into());
|
||||
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
|
||||
assert!(store.is_read_only());
|
||||
assert_eq!(read_config(tmp.path()), newer);
|
||||
}
|
||||
|
||||
// --- Review on #22: a failed migration must not leave half-migrated values in memory. ---
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_migration_in_an_unwritable_folder_shows_defaults() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Version 0 has no migration step, so upgrading fails.
|
||||
write_config(tmp.path(), "schema_version = 0\n[general]\nlanguage = \"de\"\n");
|
||||
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
let (store, report) = open_in(tmp.path());
|
||||
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(matches!(report.read_only, Some(ReadOnlyReason::DirNotWritable(_))), "{report:?}");
|
||||
assert_eq!(store.get().general.language, "en", "defaults, not half-migrated values");
|
||||
assert_eq!(report.source, Source::Defaults);
|
||||
}
|
||||
|
||||
// --- Review on #22 (skylartaylor): an outside file whose migration fails must not be overwritten. ---
|
||||
|
||||
#[test]
|
||||
fn outside_file_with_failing_migration_is_not_overwritten() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.flush().unwrap();
|
||||
// Another writer leaves a file at a version this build cannot migrate (0 has no step).
|
||||
let external = "schema_version = 0\n[general]\nlanguage = 'external'\n";
|
||||
write_config(tmp.path(), external);
|
||||
|
||||
store.update(|p| p.general.language = "local".into());
|
||||
let result = store.flush();
|
||||
assert!(matches!(result, Err(SettingsError::ReadOnly(_))), "{result:?}");
|
||||
assert!(store.is_read_only());
|
||||
assert_eq!(read_config(tmp.path()), external, "the outside file is left untouched");
|
||||
assert!(matches!(store.tick(std::time::Instant::now()).status, craft_settings::SaveStatus::Idle), "no retries once read-only");
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
mod common;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use common::{Theme, config_path, open_in, read_config, write_config};
|
||||
use craft_settings::{SaveStatus, Source};
|
||||
|
||||
#[test]
|
||||
fn first_run_gives_defaults_and_writes_the_file_on_flush() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, report) = open_in(tmp.path());
|
||||
assert_eq!(report.source, Source::Defaults);
|
||||
assert_eq!(store.get().general.language, "en");
|
||||
assert!(!config_path(tmp.path()).exists());
|
||||
|
||||
store.flush().unwrap();
|
||||
let text = read_config(tmp.path());
|
||||
assert!(text.starts_with("schema_version = 2\n"), "{text}");
|
||||
assert!(text.contains("[general]"), "{text}");
|
||||
assert!(text.contains("[appearance]"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
assert!(store.update(|p| {
|
||||
p.appearance.theme = Theme::Dark;
|
||||
p.general.recent = vec!["a.craft".into(), "b.craft".into()];
|
||||
}));
|
||||
store.flush().unwrap();
|
||||
|
||||
let (store, report) = open_in(tmp.path());
|
||||
assert_eq!(report.source, Source::File);
|
||||
assert_eq!(store.get().appearance.theme, Theme::Dark);
|
||||
assert_eq!(store.get().general.recent, vec!["a.craft", "b.craft"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_unknown_keys_and_order_survive_a_save() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_config(
|
||||
tmp.path(),
|
||||
"schema_version = 2\n\n# my notes\n[appearance]\nui_scale = 1.5 # big\nfuture_key = \"kept\"\n\n[general]\nlanguage = \"de\"\n\n[plugins]\nenabled = true\n",
|
||||
);
|
||||
let (mut store, report) = open_in(tmp.path());
|
||||
assert!(report.section_errors.is_empty(), "{report:?}");
|
||||
assert_eq!(store.get().general.language, "de");
|
||||
store.update(|p| p.appearance.theme = Theme::Dark);
|
||||
store.flush().unwrap();
|
||||
|
||||
let text = read_config(tmp.path());
|
||||
assert!(text.contains("# my notes"), "{text}");
|
||||
assert!(text.contains("ui_scale = 1.5 # big"), "{text}");
|
||||
assert!(text.contains("future_key = \"kept\""), "{text}");
|
||||
assert!(text.contains("[plugins]\nenabled = true"), "{text}");
|
||||
assert!(text.contains("theme = \"dark\""), "{text}");
|
||||
assert!(text.find("[appearance]").unwrap() < text.find("[general]").unwrap(), "section order changed:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrays_are_replaced_whole() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_config(tmp.path(), "schema_version = 2\n[general]\nrecent = [\"old1\", \"old2\", \"old3\"]\n");
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.update(|p| p.general.recent = vec!["new".into()]);
|
||||
store.flush().unwrap();
|
||||
assert!(read_config(tmp.path()).contains("recent = [\"new\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_with_no_change_is_not_dirty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
assert!(!store.update(|p| p.general.autosave_minutes = 5));
|
||||
assert!(!store.is_dirty());
|
||||
assert!(store.update(|p| p.general.autosave_minutes = 6));
|
||||
assert!(store.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_waits_for_the_debounce_and_reports_the_deadline() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
let t0 = Instant::now();
|
||||
assert!(matches!(store.tick(t0).status, SaveStatus::Idle));
|
||||
|
||||
store.update_at(t0, |p| p.appearance.theme = Theme::Dark);
|
||||
let r = store.tick(t0);
|
||||
assert!(matches!(r.status, SaveStatus::Pending));
|
||||
assert_eq!(r.next_deadline, Some(t0 + Duration::from_millis(100)));
|
||||
assert!(!config_path(tmp.path()).exists());
|
||||
|
||||
let r = store.tick(t0 + Duration::from_millis(100));
|
||||
assert!(matches!(r.status, SaveStatus::Saved), "{r:?}");
|
||||
assert_eq!(r.next_deadline, None);
|
||||
assert!(read_config(tmp.path()).contains("theme = \"dark\""));
|
||||
assert!(matches!(store.tick(t0 + Duration::from_millis(200)).status, SaveStatus::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn further_changes_push_the_save_back() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
let t0 = Instant::now();
|
||||
store.update_at(t0, |p| p.general.autosave_minutes = 1);
|
||||
store.update_at(t0 + Duration::from_millis(80), |p| p.general.autosave_minutes = 2);
|
||||
let r = store.tick(t0 + Duration::from_millis(100));
|
||||
assert!(matches!(r.status, SaveStatus::Pending));
|
||||
assert_eq!(r.next_deadline, Some(t0 + Duration::from_millis(180)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_save_is_retried_with_backoff() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
// A folder where the file should be makes the write fail.
|
||||
std::fs::create_dir(config_path(tmp.path())).unwrap();
|
||||
let t0 = Instant::now();
|
||||
store.update_at(t0, |p| p.appearance.theme = Theme::Dark);
|
||||
|
||||
let r = store.tick(t0 + Duration::from_millis(100));
|
||||
assert!(matches!(r.status, SaveStatus::Failed(_)), "{r:?}");
|
||||
let first_retry = t0 + Duration::from_millis(100) + Duration::from_secs(1);
|
||||
assert_eq!(r.next_deadline, Some(first_retry));
|
||||
assert!(matches!(store.tick(first_retry - Duration::from_millis(1)).status, SaveStatus::Pending));
|
||||
|
||||
let r = store.tick(first_retry);
|
||||
assert!(matches!(r.status, SaveStatus::Failed(_)));
|
||||
assert_eq!(r.next_deadline, Some(first_retry + Duration::from_secs(2)));
|
||||
|
||||
std::fs::remove_dir(config_path(tmp.path())).unwrap();
|
||||
let r = store.tick(first_retry + Duration::from_secs(2));
|
||||
assert!(matches!(r.status, SaveStatus::Saved), "{r:?}");
|
||||
assert!(read_config(tmp.path()).contains("theme = \"dark\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_delay_doubles_and_caps() {
|
||||
use craft_settings::store::retry_delay;
|
||||
assert_eq!(retry_delay(1), Duration::from_secs(1));
|
||||
assert_eq!(retry_delay(2), Duration::from_secs(2));
|
||||
assert_eq!(retry_delay(3), Duration::from_secs(4));
|
||||
assert_eq!(retry_delay(6), Duration::from_secs(30));
|
||||
assert_eq!(retry_delay(40), Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_holds_the_previous_version() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.update(|p| p.general.language = "fr".into());
|
||||
store.flush().unwrap();
|
||||
store.update(|p| p.general.language = "es".into());
|
||||
store.flush().unwrap();
|
||||
let bak = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
|
||||
assert!(bak.contains("language = \"fr\""), "{bak}");
|
||||
assert!(read_config(tmp.path()).contains("language = \"es\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_edits_between_load_and_save_are_kept() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.flush().unwrap();
|
||||
store.update(|p| p.general.language = "fr".into());
|
||||
|
||||
// Another instance (or the user) edits the file meanwhile.
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
let edited = read_config(tmp.path()).replace("[general]", "# added by hand\n[other]\nx = 1\n\n[general]");
|
||||
write_config(tmp.path(), &edited);
|
||||
let later = std::time::SystemTime::now() + Duration::from_secs(2);
|
||||
std::fs::File::options().write(true).open(config_path(tmp.path())).unwrap().set_modified(later).unwrap();
|
||||
|
||||
store.flush().unwrap();
|
||||
let text = read_config(tmp.path());
|
||||
assert!(text.contains("# added by hand"), "{text}");
|
||||
assert!(text.contains("[other]\nx = 1"), "{text}");
|
||||
assert!(text.contains("language = \"fr\""), "{text}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn new_file_is_owner_only_and_keeps_its_mode_on_replace() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.flush().unwrap();
|
||||
let mode = std::fs::metadata(config_path(tmp.path())).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
|
||||
std::fs::set_permissions(config_path(tmp.path()), std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
store.update(|p| p.general.language = "fr".into());
|
||||
store.flush().unwrap();
|
||||
let mode = std::fs::metadata(config_path(tmp.path())).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o644, "a user's deliberate mode change is kept");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_only_file_makes_the_save_fail_visibly() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.flush().unwrap();
|
||||
std::fs::set_permissions(config_path(tmp.path()), std::fs::Permissions::from_mode(0o400)).unwrap();
|
||||
store.update(|p| p.general.language = "fr".into());
|
||||
let r = store.tick(Instant::now() + Duration::from_secs(1));
|
||||
assert!(matches!(r.status, SaveStatus::Failed(craft_settings::SettingsError::Io(_))), "{r:?}");
|
||||
assert!(!read_config(tmp.path()).contains("language = \"fr\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_edit_with_the_same_mtime_is_still_seen() {
|
||||
// Coarse timestamps (FAT, HFS+, network shares) can leave mtime unchanged after an edit.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.flush().unwrap();
|
||||
let mtime = std::fs::metadata(config_path(tmp.path())).unwrap().modified().unwrap();
|
||||
|
||||
let edited = read_config(tmp.path()).replace("[general]", "# same second\n[general]");
|
||||
write_config(tmp.path(), &edited);
|
||||
std::fs::File::options().write(true).open(config_path(tmp.path())).unwrap().set_modified(mtime).unwrap();
|
||||
|
||||
store.update(|p| p.general.language = "fr".into());
|
||||
store.flush().unwrap();
|
||||
let text = read_config(tmp.path());
|
||||
assert!(text.contains("# same second"), "{text}");
|
||||
assert!(text.contains("language = \"fr\""), "{text}");
|
||||
}
|
||||
|
||||
// --- Review on #22 (skylartaylor): the backup must never be more readable than config.toml. ---
|
||||
|
||||
#[cfg(unix)]
|
||||
fn mode(path: &std::path::Path) -> u32 {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn new_backup_is_as_private_as_the_config() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.flush().unwrap();
|
||||
store.update(|p| p.general.language = "private".into());
|
||||
store.flush().unwrap();
|
||||
assert_eq!(mode(&config_path(tmp.path())), 0o600);
|
||||
assert_eq!(mode(&tmp.path().join("config.toml.bak")), 0o600);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn existing_broader_backup_is_tightened() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.flush().unwrap();
|
||||
// A backup left behind by an older build, readable by everyone.
|
||||
let bak = tmp.path().join("config.toml.bak");
|
||||
std::fs::write(&bak, b"old backup").unwrap();
|
||||
std::fs::set_permissions(&bak, std::fs::Permissions::from_mode(0o664)).unwrap();
|
||||
|
||||
store.update(|p| p.general.language = "private".into());
|
||||
store.flush().unwrap();
|
||||
assert_eq!(mode(&bak), mode(&config_path(tmp.path())), "backup must not be more readable than config.toml");
|
||||
assert_eq!(mode(&bak), 0o600);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn backup_follows_a_mode_the_user_chose_for_the_config() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (mut store, _) = open_in(tmp.path());
|
||||
store.flush().unwrap();
|
||||
std::fs::set_permissions(config_path(tmp.path()), std::fs::Permissions::from_mode(0o640)).unwrap();
|
||||
store.update(|p| p.general.language = "fr".into());
|
||||
store.flush().unwrap();
|
||||
assert_eq!(mode(&tmp.path().join("config.toml.bak")), 0o640);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# Settings storage across the Craft apps
|
||||
|
||||
How every Craft app stores its settings, and what goes where. The shared code is
|
||||
[`craft-settings`](../crates/settings/README.md); this page is the policy it implements.
|
||||
Discussion: [storytold/craft-libs#12](https://github.com/storytold/craft-libs/issues/12).
|
||||
|
||||
## What goes where
|
||||
|
||||
| Kind | Where | Why |
|
||||
|---|---|---|
|
||||
| Settings people might edit | `config.toml` in the config folder | One readable file with comments, atomic saves, backup, recovery, migrations |
|
||||
| Window and UI state | `ui.ron` / `app.ron` beside `config.toml` | Changes every session; not worth comments or hand edits |
|
||||
| Caches and models | Platform cache folder (`paths::cache_dir`); `%LOCALAPPDATA%` on Windows so it does not roam | Disposable, large |
|
||||
| Recovery and autosave files | Platform data folder (`paths::data_dir`) | Not settings; must survive a settings reset |
|
||||
| Settings that belong to a library or project (LightCraft) | Inside that folder, via `SettingsStore::open_at` | Travels with the library |
|
||||
| API keys and tokens | The OS keychain | Never in a plain-text file |
|
||||
|
||||
## The config folder
|
||||
|
||||
First match wins. The store reports which rule applied, so apps can show the path in their
|
||||
about or settings screen.
|
||||
|
||||
1. An override passed by the app, such as a `--config-dir` flag
|
||||
2. `<APP>_CONFIG_DIR`, for example `PHOTOCRAFT_CONFIG_DIR` (one per app, so redirecting one
|
||||
app does not move the others). Set but **empty** means isolated: nothing is read or written
|
||||
anywhere, the same as DesignCraft's existing behaviour, so tests and agents can never touch a
|
||||
user's real settings
|
||||
3. Portable mode: a marker file next to the executable (next to the `.app` on macOS, since
|
||||
writing inside a signed bundle breaks its signature). The default is a file named `portable`
|
||||
with settings in `config/` beside it; apps that already ship portable builds keep their own
|
||||
names (PhotoCraft: `portable.txt` or `PhotoCraft.portable`, folder `PhotoCraftData`)
|
||||
4. The platform default:
|
||||
|
||||
Apps can also open a store with `Location::Memory` (for a switch like `DESIGNCRAFT_NO_PREFS`):
|
||||
settings work in memory and nothing touches the disk.
|
||||
|
||||
| Platform | Folder |
|
||||
|---|---|
|
||||
| Linux / BSD | `$XDG_CONFIG_HOME/<app>/`, or `~/.config/<app>/` if unset |
|
||||
| macOS | `~/Library/Application Support/<app>/` |
|
||||
| Windows | `%APPDATA%\<app>\` (Roaming) |
|
||||
|
||||
`<app>` is the app's lowercase id (`photocraft`, `designcraft`, …) on every platform, macOS
|
||||
included (decided in #12). Existing Linux folder names do not change; the Launcher moves from
|
||||
`ai.storyteller.artcraft-launcher` to `artcraft-launcher`.
|
||||
|
||||
If the folder cannot be created or written (a portable marker in `/Applications`, an AppImage
|
||||
mount), the store opens read-only and says so at startup.
|
||||
|
||||
## Files in the folder
|
||||
|
||||
- `config.toml`: starts with `schema_version = N`, then one `[section]` per settings area.
|
||||
Written on first launch so people can find it. `0600` on Unix.
|
||||
- `config.toml.bak`: the previous version, refreshed before every save.
|
||||
- `config.broken-<unix-ts>.toml`: a file that was not valid TOML, moved aside and never deleted.
|
||||
|
||||
## Saving and recovery
|
||||
|
||||
- Saves happen during use, half a second after the last change, atomically through
|
||||
`craft-atomic`. A failed save leaves the old file intact and is retried with backoff; the app
|
||||
shows the error.
|
||||
- A file with a TOML syntax error is moved aside and defaults are used. A file that parses but
|
||||
has a value that no longer fits (a renamed theme, a typo in a hand edit) is not treated as
|
||||
broken: only that `[section]` falls back to defaults and the app is told which one.
|
||||
- A file written by a newer build is shown but never overwritten.
|
||||
- Comments, key order and keys the app does not know survive saves. If two instances of an app
|
||||
are open, each re-reads the file before writing so those survive; known keys are
|
||||
last-writer-wins.
|
||||
|
||||
## Migration
|
||||
|
||||
- Schema changes: `Settings::migrate` upgrades the document one version at a time before it is
|
||||
parsed. A failed step leaves the file untouched and the store read-only.
|
||||
- Older per-app files (`prefs.json`, `ui.json`, `*.ron`): imported on the first run that finds no
|
||||
`config.toml`, using the app's existing parser. The old file is **left in place**, so rolling
|
||||
back to an older build keeps working; apps can remove it a few releases later.
|
||||
|
||||
## Where the apps are today
|
||||
|
||||
From the audit in #12 (Linux defaults, from source; overrides and portable mode change them).
|
||||
Before an app moves over, check where it saves on macOS and Windows so the importer finds the old
|
||||
files there too.
|
||||
|
||||
| App | Today | Moving to `craft-settings` |
|
||||
|---|---|---|
|
||||
| PhotoCraft | `~/.config/photocraft/preferences.json` + eframe `ui.ron`; atomic saves with retries; portable via `portable.txt`/`PhotoCraft.portable` → `PhotoCraftData/` | Import `preferences.json`; keep `ui.ron`; keep its portable names via `PORTABLE_MARKERS`/`PORTABLE_DIR` |
|
||||
| DesignCraft | `~/.config/designcraft/prefs.json` + `ui.json`; saves on exit, ignores failures; empty `DESIGNCRAFT_CONFIG_DIR` and `DESIGNCRAFT_NO_PREFS` touch nothing | Import `prefs.json`; keep `ui.json`; gain save-during-use and error reporting; `DESIGNCRAFT_NO_PREFS` → `Location::Memory` |
|
||||
| EffectCraft | `~/.config/effectcraft/prefs.json`, `shortcuts.json`, `script_settings.json`; atomic | Import all three into sections of one `config.toml` |
|
||||
| FilmCraft | `~/.local/share/filmcraft/preferences.json`; atomic | Import from the data folder; settings move to the config folder |
|
||||
| GridCraft | `~/.config/gridcraft/prefs.json` + `ui.json`; saves on exit, ignores failures | Same as DesignCraft |
|
||||
| LightCraft | global `ui.json`; library `prefs.json`, `presets.json`, `view.json`; atomic, backups, overwrite protection | Global settings via `open`; library settings via `open_at(library_dir)` |
|
||||
| VectorCraft | `~/.config/vectorcraft/ui.json` with engine preferences inside; atomic, ignores failures | Split engine preferences out into `config.toml`; keep UI state in `ui.json` |
|
||||
| ArtCraft Launcher | `~/.local/share/ai.storyteller.artcraft-launcher/app.ron` (eframe) | Import from `app.ron`; needs its own tick timer while in the tray |
|
||||
|
||||
Adoption order: PhotoCraft and DesignCraft first (closest to and furthest from the target
|
||||
behaviour), then the rest.
|
||||
|
||||
## Not yet
|
||||
|
||||
- Reloading settings when the file changes on disk while the app runs
|
||||
- Removing imported legacy files
|
||||
- Settings on the web (`wasm32`), where only the trait, document helpers and error types exist
|
||||
Reference in New Issue
Block a user