Skip to main content

qframe/storage/
preferences.rs

1//! The preferences every application of an ecosystem shares: language, theme, icons and reduced
2//! motion.
3//!
4//! The ecosystem's shared file holds one value of each, and each application's own file either
5//! names its own value or the ecosystem's id, which means "use the shared one":
6//!
7//! ```toml
8//! # quvyta.conf
9//! language = "tr"
10//! theme = "monochrome"
11//! icons = "nerd"
12//! reduced-motion = true
13//!
14//! # code.conf
15//! language = "quvyta"
16//! theme = "nordic"
17//! reduced-motion = "quvyta"
18//! ```
19//!
20//! [`Ecosystem::preferences`] resolves each key on its own, in this order:
21//!
22//! 1. the application's value, when it is anything but the ecosystem's id;
23//! 2. the shared file's value, when the application's value is the ecosystem's id or the key is
24//!    missing from the application's file;
25//! 3. the value detected on this machine, when the shared file does not hold one either.
26//!
27//! [`Ecosystem::set`] changes one key for the whole ecosystem or for one application, and
28//! [`Ecosystem::follow`] puts one application back on the ecosystem's value without touching it.
29
30use std::fs;
31use std::io;
32use std::path::{Path, PathBuf};
33
34use super::{Ecosystem, SettingValue, Settings, atomic_write};
35use crate::diagnostics::{Diagnostic, Severity};
36use crate::i18n::I18n;
37use crate::icons::{GlyphMode, IconMode, default_font_dirs, detect_glyph_mode};
38use crate::runtime::Command;
39
40/// The theme a machine starts with: every theme is dark, so there is nothing to detect.
41const DETECTED_THEME: &str = "monochrome";
42
43/// The language when the system names none the application speaks.
44const FALLBACK_LANGUAGE: &str = "en";
45
46/// A preference every application of an ecosystem shares.
47///
48/// More may be added in a later release, so a `match` on it needs a `_` arm; iterate
49/// [`Shared::ALL`] to list them.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51#[non_exhaustive]
52pub enum Shared {
53    /// The language, a locale code such as `tr`.
54    Language,
55    /// The colour theme, a theme id such as `nordic`.
56    Theme,
57    /// The icon mode: `auto`, `nerd`, `unicode` or `ascii`.
58    Icons,
59    /// Reduced motion, `true` or `false`: a need of the person rather than a look of one
60    /// application, so it is shared like the language. Written as a boolean; its text form, in
61    /// [`Ecosystem::set`], is `true` or `false`.
62    ReducedMotion,
63}
64
65impl Shared {
66    /// Every shared preference, in the order a settings screen lists them.
67    pub const ALL: [Self; 4] = [Self::Language, Self::Theme, Self::Icons, Self::ReducedMotion];
68
69    /// The key the preference is written under, in the shared file and in each application's.
70    #[must_use]
71    pub fn key(self) -> &'static str {
72        match self {
73            Self::Language => Settings::LANGUAGE,
74            Self::Theme => Settings::THEME,
75            Self::Icons => Settings::ICONS,
76            Self::ReducedMotion => Settings::REDUCED_MOTION,
77        }
78    }
79
80    /// The value under this key in `settings`, in its text form: a boolean as `true` or `false`,
81    /// the rest as written. `None` when missing or of a type the key never holds.
82    pub(crate) fn read(self, settings: &Settings) -> Option<String> {
83        match settings.value(self.key())? {
84            SettingValue::Text(text) => Some(text.clone()),
85            SettingValue::Bool(flag) if self == Self::ReducedMotion => Some(flag.to_string()),
86            _ => None,
87        }
88    }
89
90    /// `text` as it is written under this key: reduced motion's `true` and `false` as booleans,
91    /// every other text, the ecosystem's id among them, as text.
92    pub(crate) fn setting(self, text: &str) -> SettingValue {
93        match (self, text.parse::<bool>()) {
94            (Self::ReducedMotion, Ok(flag)) => SettingValue::Bool(flag),
95            _ => SettingValue::Text(text.to_owned()),
96        }
97    }
98}
99
100/// Where [`Ecosystem::set`] writes a change: the "In every Quvyta application" choice of a settings
101/// screen.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum Scope {
104    /// The shared file takes the value and the application follows it again, so every
105    /// application that follows the ecosystem changes with it. Applications that chose their own
106    /// value keep it.
107    Ecosystem,
108    /// Only the application's own file takes the value; the shared file is left alone.
109    App,
110}
111
112impl Scope {
113    /// The former name of [`Scope::Ecosystem`], still working so applications can move over; a
114    /// later release marks it deprecated. New code uses [`Scope::Ecosystem`].
115    #[allow(non_upper_case_globals)]
116    pub const Family: Scope = Scope::Ecosystem;
117}
118
119/// Where a resolved preference came from, so a settings screen can say "follows every Quvyta
120/// application" or "only here".
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
122pub enum Source {
123    /// The application's own file names it.
124    App,
125    /// The ecosystem's shared file holds it and the application follows it.
126    Ecosystem,
127    /// Neither file holds it; it was detected on this machine.
128    Detected,
129}
130
131impl Source {
132    /// The former name of [`Source::Ecosystem`], still working so applications can move over; a
133    /// later release marks it deprecated. New code uses [`Source::Ecosystem`].
134    #[allow(non_upper_case_globals)]
135    pub const Family: Source = Source::Ecosystem;
136}
137
138/// A preference's value together with where it came from.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Resolved<T> {
141    /// The value to use.
142    pub value: T,
143    /// Where the value came from.
144    pub source: Source,
145}
146
147/// The shared preferences as one application sees them, from [`Ecosystem::preferences`].
148///
149/// ```
150/// use qframe::i18n::I18n;
151/// use qframe::storage::{Ecosystem, Source};
152///
153/// # let folder = std::env::temp_dir().join(format!("quvyta-preferences-doc-{}", std::process::id()));
154/// // An application calls `Ecosystem::QUVYTA.preferences("code", &i18n)`; the example keeps to a
155/// // folder of its own.
156/// let prefs = Ecosystem::QUVYTA.preferences_in(&folder, "code", &I18n::builtin());
157/// let theme = prefs.theme();
158/// assert_eq!((theme.value.as_str(), theme.source), ("monochrome", Source::Detected), "never detected");
159/// assert!(folder.join("quvyta.conf").is_file(), "the first start writes the shared file");
160/// # std::fs::remove_dir_all(&folder).ok();
161/// ```
162#[derive(Debug, Clone, PartialEq)]
163pub struct Preferences {
164    language: Resolved<String>,
165    theme: Resolved<String>,
166    icons: Resolved<IconMode>,
167    reduced_motion: Resolved<bool>,
168    update_notice: bool,
169    diagnostics: Vec<Diagnostic>,
170}
171
172impl Preferences {
173    /// Whether the ecosystem's applications say when a newer version of themselves is out; see
174    /// [`Ecosystem::update_notice`]. One switch for the whole ecosystem, on unless it was turned off.
175    #[must_use]
176    pub fn update_notice(&self) -> bool {
177        self.update_notice
178    }
179
180    /// Records that the update notice is now `on`, after a change written with
181    /// [`Ecosystem::set_update_notice`].
182    pub(crate) fn record_update_notice(&mut self, on: bool) {
183        self.update_notice = on;
184    }
185
186    /// The locale code to speak.
187    #[must_use]
188    pub fn language(&self) -> &Resolved<String> {
189        &self.language
190    }
191
192    /// The theme id to draw with.
193    #[must_use]
194    pub fn theme(&self) -> &Resolved<String> {
195        &self.theme
196    }
197
198    /// The icon mode to draw with.
199    #[must_use]
200    pub fn icons(&self) -> &Resolved<IconMode> {
201        &self.icons
202    }
203
204    /// Whether to reduce motion. The `QUVYTA_REDUCED_MOTION` environment variable, when set,
205    /// still decides over it in the running application; see
206    /// [`Env::reduced_motion`](crate::env::Env::reduced_motion).
207    #[must_use]
208    pub fn reduced_motion(&self) -> &Resolved<bool> {
209        &self.reduced_motion
210    }
211
212    /// Where `key` came from.
213    #[must_use]
214    pub fn source(&self, key: Shared) -> Source {
215        match key {
216            Shared::Language => self.language.source,
217            Shared::Theme => self.theme.source,
218            Shared::Icons => self.icons.source,
219            Shared::ReducedMotion => self.reduced_motion.source,
220        }
221    }
222
223    /// Records that `key` now holds `value`, which came from `source`, after a change written
224    /// with [`Ecosystem::set`].
225    pub(crate) fn record(&mut self, key: Shared, value: &str, source: Source) {
226        match key {
227            Shared::Language => self.language = Resolved { value: value.to_owned(), source },
228            Shared::Theme => self.theme = Resolved { value: value.to_owned(), source },
229            Shared::Icons => {
230                let mode = IconMode::from_name(value).unwrap_or(self.icons.value);
231                self.icons = Resolved { value: mode, source };
232            }
233            Shared::ReducedMotion => {
234                let reduced = value.parse().unwrap_or(self.reduced_motion.value);
235                self.reduced_motion = Resolved { value: reduced, source };
236            }
237        }
238    }
239
240    /// The value of `key` as it is written in a file.
241    pub(crate) fn text(&self, key: Shared) -> String {
242        match key {
243            Shared::Language => self.language.value.clone(),
244            Shared::Theme => self.theme.value.clone(),
245            Shared::Icons => self.icons.value.name().to_owned(),
246            Shared::ReducedMotion => self.reduced_motion.value.to_string(),
247        }
248    }
249
250    /// Problems found on the way: a broken line in the shared file, a shared file that could not
251    /// be written. Each is located where a file is to blame; the key it concerns fell back to the
252    /// detected value. Problems in the application's own file are reported by the application's
253    /// own [`Settings`] load and are not repeated here.
254    #[must_use]
255    pub fn diagnostics(&self) -> &[Diagnostic] {
256        &self.diagnostics
257    }
258
259    /// Commands that switch the running application to the resolved language, theme, icons and
260    /// reduced motion, for use after a change in `update`. At start give the preferences to
261    /// [`Runtime::preferences`](crate::runtime::Runtime::preferences) instead, so the first
262    /// frame is already drawn with them.
263    #[must_use]
264    pub fn apply<Msg: Send + 'static>(&self) -> Command<Msg> {
265        Command::batch([
266            Command::set_theme(self.theme.value.clone()),
267            Command::set_locale(self.language.value.clone()),
268            Command::set_icon_mode(self.icons.value),
269            Command::set_reduced_motion(self.reduced_motion.value),
270        ])
271    }
272}
273
274/// What a resolution does about a shared file that is not there.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276enum Missing {
277    /// Write it with the detected values, so the next application finds them.
278    Create,
279    /// Leave it missing and detect its keys, so nothing is written before a setup wizard finishes.
280    Leave,
281}
282
283/// What this machine would choose for each shared preference.
284struct Detected {
285    language: String,
286    icons: IconMode,
287}
288
289impl Detected {
290    fn on_this_machine(i18n: &I18n, lookup: impl Fn(&str) -> Option<String>, font_dirs: &[PathBuf]) -> Self {
291        let language = i18n.detect(&lookup).unwrap_or_else(|| FALLBACK_LANGUAGE.to_owned());
292        let icons = match detect_glyph_mode(IconMode::Auto, &lookup, font_dirs) {
293            GlyphMode::Nerd => IconMode::Nerd,
294            GlyphMode::Unicode => IconMode::Unicode,
295            GlyphMode::Ascii => IconMode::Ascii,
296        };
297        Self { language, icons }
298    }
299
300    /// The detected value of `key` as it is written in a file.
301    fn text(&self, key: Shared) -> String {
302        match key {
303            Shared::Language => self.language.clone(),
304            Shared::Theme => DETECTED_THEME.to_owned(),
305            Shared::Icons => self.icons.name().to_owned(),
306            Shared::ReducedMotion => false.to_string(),
307        }
308    }
309
310    /// The shared file as it is first written: the detected value of language, theme and icons.
311    /// Reduced motion is left out until someone chooses it, as in every shared file written before
312    /// it was shared; missing, it reads as motion.
313    fn file(&self) -> String {
314        let mut settings = Settings::in_memory();
315        for key in [Shared::Language, Shared::Theme, Shared::Icons] {
316            settings.set(key.key(), self.text(key));
317        }
318        settings.to_toml()
319    }
320}
321
322impl Ecosystem {
323    /// Resolves the shared preferences of application `app`: for each of language, theme, icons
324    /// and reduced motion, the application's own value when its file names one other than the ecosystem's id,
325    /// else the value in the [shared file](Self::shared_file), else the value detected on this
326    /// machine. A key missing from the application's file follows the ecosystem, as the ecosystem's id
327    /// does, so a file written by hand before the ecosystem shared anything follows it too.
328    ///
329    /// Detection reads the environment: the language as [`I18n::detect`] finds it among the
330    /// languages `i18n` knows (English when it knows none of the system's), the theme always
331    /// `monochrome`, the icons as the strongest set the terminal and the installed fonts allow
332    /// ([`detect_glyph_mode`]), reduced motion always off.
333    ///
334    /// When the shared file does not exist it is created with the detected values, so the next
335    /// application that starts finds them. A broken line never stops anything: that key falls
336    /// back to the detected value and the reason, located at file, line and column, is in
337    /// [`Preferences::diagnostics`]. Without a home folder nothing is read or written and every
338    /// value is detected.
339    ///
340    /// The rest of the application's settings are read as before, with
341    /// [`Settings::load_member`]; this only resolves the keys [`Shared`] names.
342    #[must_use]
343    pub fn preferences(&self, app: &str, i18n: &I18n) -> Preferences {
344        let lookup = |name: &str| std::env::var(name).ok();
345        let font_dirs = default_font_dirs(lookup);
346        let detected = Detected::on_this_machine(i18n, lookup, &font_dirs);
347        match self.config_dir() {
348            Some(dir) => self.resolve(&dir, app, &detected, Missing::Create),
349            None => {
350                let mut prefs = resolved_from(&detected, |_| None, |_| None);
351                prefs
352                    .diagnostics
353                    .push(Diagnostic::warning(None, "no config directory found; preferences are not saved"));
354                prefs
355            }
356        }
357    }
358
359    /// [`preferences`](Self::preferences) with `config_dir` as the ecosystem's folder instead of
360    /// this platform's, for a test or a demo that must leave the user's own files alone.
361    #[must_use]
362    pub fn preferences_in(&self, config_dir: &Path, app: &str, i18n: &I18n) -> Preferences {
363        let lookup = |name: &str| std::env::var(name).ok();
364        let detected = Detected::on_this_machine(i18n, lookup, &default_font_dirs(lookup));
365        self.resolve(config_dir, app, &detected, Missing::Create)
366    }
367
368    /// [`preferences`](Self::preferences) without writing anything: a missing shared file is left
369    /// missing and its keys are detected instead.
370    ///
371    /// For an application whose first start shows a [setup wizard](crate::widgets::Setup): a
372    /// wizard closed half-way leaves the user's settings folder as empty as it found it, and the
373    /// wizard's Finish writes both files. An application without a wizard uses
374    /// [`preferences`](Self::preferences), so the first application to start leaves the shared
375    /// file for the next one.
376    #[must_use]
377    pub fn preferences_without_saving(&self, app: &str, i18n: &I18n) -> Preferences {
378        let lookup = |name: &str| std::env::var(name).ok();
379        let font_dirs = default_font_dirs(lookup);
380        let detected = Detected::on_this_machine(i18n, lookup, &font_dirs);
381        match self.config_dir() {
382            Some(dir) => self.resolve(&dir, app, &detected, Missing::Leave),
383            None => resolved_from(&detected, |_| None, |_| None),
384        }
385    }
386
387    /// [`preferences_without_saving`](Self::preferences_without_saving) with `config_dir` as the
388    /// ecosystem's folder instead of this platform's, for a test or a demo.
389    #[must_use]
390    pub fn preferences_without_saving_in(&self, config_dir: &Path, app: &str, i18n: &I18n) -> Preferences {
391        let lookup = |name: &str| std::env::var(name).ok();
392        let detected = Detected::on_this_machine(i18n, lookup, &default_font_dirs(lookup));
393        self.resolve(config_dir, app, &detected, Missing::Leave)
394    }
395
396    /// [`preferences_in`](Self::preferences_in) with the machine's detection read through
397    /// `lookup` and `font_dirs` instead of the process environment, for tests.
398    #[cfg(test)]
399    fn preferences_detecting(
400        &self,
401        config_dir: &Path,
402        app: &str,
403        i18n: &I18n,
404        lookup: impl Fn(&str) -> Option<String>,
405    ) -> Preferences {
406        self.resolve(config_dir, app, &Detected::on_this_machine(i18n, lookup, &[]), Missing::Create)
407    }
408
409    /// Changes shared preference `key` of application `app` to `value`, for the whole ecosystem or
410    /// for the application alone:
411    ///
412    /// | `scope` | shared file | application's file |
413    /// |---|---|---|
414    /// | [`Scope::Ecosystem`] | `key = value` | `key = "<ecosystem id>"` |
415    /// | [`Scope::App`] | unchanged | `key = value` |
416    ///
417    /// Each file is read from disk right before it is written and only `key` changes in it, so
418    /// two applications changing preferences at the same time both keep their change instead
419    /// of one writing back what it read earlier. On Unix systems the ecosystem's folder is held
420    /// with an advisory lock from the reading to the writing, so even two changes in the same
421    /// instant follow one another; elsewhere the window between them is a few microseconds. Files are written with [`atomic_write`]; the
422    /// other keys stay as they were, though comments do not survive, as with
423    /// [`Settings::save`]. The running application is not switched; use
424    /// [`Preferences::apply`] or the matching [`Command`] for that.
425    ///
426    /// # Errors
427    ///
428    /// Returns an error of kind [`io::ErrorKind::InvalidInput`] when `value` is not a valid
429    /// value of `key` (an unknown icon mode, the ecosystem's own id, an empty text), of kind
430    /// [`io::ErrorKind::NotFound`] when there is no home folder, and any error from writing.
431    pub fn set(&self, app: &str, key: Shared, value: &str, scope: Scope) -> io::Result<()> {
432        match self.config_dir() {
433            Some(dir) => self.set_in(&dir, app, key, value, scope),
434            None => Err(io::Error::new(io::ErrorKind::NotFound, "no config directory found")),
435        }
436    }
437
438    /// [`set`](Self::set) with `config_dir` as the ecosystem's folder instead of this platform's.
439    ///
440    /// # Errors
441    ///
442    /// As [`set`](Self::set), except that there is always a folder.
443    pub fn set_in(&self, config_dir: &Path, app: &str, key: Shared, value: &str, scope: Scope) -> io::Result<()> {
444        let value = self.checked(key, value)?;
445        fs::create_dir_all(config_dir)?;
446        let _held = hold_folder(config_dir)?;
447        let app_file = config_dir.join(super::ecosystem::file_name(app));
448        match scope {
449            Scope::Ecosystem => {
450                let shared_file = config_dir.join(super::ecosystem::file_name(self.id()));
451                rewrite(&shared_file, key.key(), key.setting(&value), None)?;
452                rewrite(&app_file, key.key(), SettingValue::Text(self.id().to_owned()), Some(self))
453            }
454            Scope::App => rewrite(&app_file, key.key(), key.setting(&value), Some(self)),
455        }
456    }
457
458    /// Puts application `app` back on the ecosystem's value of `key`: its own file says the ecosystem's
459    /// id and the [shared file](Self::shared_file) is neither read nor written, so the next
460    /// resolution answers the shared value with [`Source::Ecosystem`] and no other application
461    /// changes. The one way back from a value of an application's own, for the settings screen
462    /// that lists every member of the ecosystem: "follow the shared setting" on one member's cell
463    /// must not change what the whole ecosystem draws with.
464    ///
465    /// The file is read from disk right before it is written and only `key` changes in it, as
466    /// [`set`](Self::set) does, with the ecosystem's folder held by an advisory lock on Unix. A
467    /// missing file is created holding that one key. A key that already follows the ecosystem is
468    /// left alone, file and all. Comments do not survive a change, as with [`Settings::save`].
469    /// The running application is not switched; use [`Preferences::apply`] for that.
470    ///
471    /// # Errors
472    ///
473    /// Returns an error of kind [`io::ErrorKind::NotFound`] when there is no home folder, of kind
474    /// [`io::ErrorKind::InvalidData`] when the application's file cannot be read as settings,
475    /// and any error from writing. A file that could not be read is left exactly as it was.
476    pub fn follow(&self, app: &str, key: Shared) -> io::Result<()> {
477        match self.config_dir() {
478            Some(dir) => self.follow_in(&dir, app, key),
479            None => Err(io::Error::new(io::ErrorKind::NotFound, "no config directory found")),
480        }
481    }
482
483    /// [`follow`](Self::follow) with `config_dir` as the ecosystem's folder instead of this
484    /// platform's, for a test or a demo that must leave the user's own files alone.
485    ///
486    /// ```
487    /// use qframe::storage::{Ecosystem, Shared};
488    ///
489    /// # let folder = std::env::temp_dir().join(format!("quvyta-follow-doc-{}", std::process::id()));
490    /// # std::fs::create_dir_all(&folder).expect("folder");
491    /// std::fs::write(folder.join("code.conf"), "theme = \"amber\"\n").expect("the file");
492    /// Ecosystem::QUVYTA.follow_in(&folder, "code", Shared::Theme).expect("follow");
493    /// assert_eq!(std::fs::read_to_string(folder.join("code.conf")).expect("read"), "theme = \"quvyta\"\n");
494    /// assert!(!folder.join("quvyta.conf").exists(), "the shared file is left alone");
495    /// # std::fs::remove_dir_all(&folder).ok();
496    /// ```
497    ///
498    /// # Errors
499    ///
500    /// As [`follow`](Self::follow), except that there is always a folder.
501    pub fn follow_in(&self, config_dir: &Path, app: &str, key: Shared) -> io::Result<()> {
502        fs::create_dir_all(config_dir)?;
503        let _held = hold_folder(config_dir)?;
504        let path = config_dir.join(super::ecosystem::file_name(app));
505        let mut settings = Settings::open(&path).member_of(self);
506        if let Some(problem) = settings.diagnostics().iter().find(|problem| problem.severity == Severity::Error) {
507            return Err(io::Error::new(io::ErrorKind::InvalidData, problem.to_string()));
508        }
509        let value = SettingValue::Text(self.id().to_owned());
510        if settings.value(key.key()) == Some(&value) && path.exists() {
511            return Ok(());
512        }
513        settings.store(key.key(), value);
514        settings.save()
515    }
516
517    /// Changes `key` in application `app`'s own file in `config_dir` to `value`, the way
518    /// [`set_in`](Self::set_in) changes a shared key: read right before writing, only that key,
519    /// with the folder held. For the application's settings that sit beside the shared ones on an
520    /// appearance screen, such as reduced motion.
521    pub(crate) fn set_own_in(&self, config_dir: &Path, app: &str, key: &str, value: SettingValue) -> io::Result<()> {
522        fs::create_dir_all(config_dir)?;
523        let _held = hold_folder(config_dir)?;
524        rewrite(&config_dir.join(super::ecosystem::file_name(app)), key, value, Some(self))
525    }
526
527    /// `value` as it is written under `key`, or why it cannot be.
528    fn checked(&self, key: Shared, value: &str) -> io::Result<String> {
529        let invalid = |why: String| io::Error::new(io::ErrorKind::InvalidInput, why);
530        let value = value.trim();
531        if value.is_empty() {
532            return Err(invalid(format!("`{}` cannot be empty", key.key())));
533        }
534        if value == self.id() {
535            return Err(invalid(format!("`{}` cannot be set to the ecosystem's own id `{value}`", key.key())));
536        }
537        match key {
538            Shared::Icons => IconMode::from_name(value)
539                .map(|mode| mode.name().to_owned())
540                .ok_or_else(|| invalid(format!("`{value}` is not an icon mode; use auto, nerd, unicode or ascii"))),
541            Shared::ReducedMotion => value
542                .parse::<bool>()
543                .map(|reduced| reduced.to_string())
544                .map_err(|_| invalid(format!("`{value}` is not a value of reduced motion; use true or false"))),
545            Shared::Language | Shared::Theme => Ok(value.to_owned()),
546        }
547    }
548
549    /// Resolves the preferences of `app` from the files in `config_dir`, creating the shared file
550    /// with the `detected` values when it is missing and `missing` says to.
551    fn resolve(&self, config_dir: &Path, app: &str, detected: &Detected, missing: Missing) -> Preferences {
552        let mut diagnostics = Vec::new();
553        let shared_path = config_dir.join(super::ecosystem::file_name(self.id()));
554        let shared = if shared_path.exists() {
555            let shared = Settings::open(&shared_path);
556            diagnostics.extend(shared.diagnostics().iter().cloned());
557            Some(shared)
558        } else {
559            if missing == Missing::Create
560                && let Err(error) = create(&shared_path, &detected.file())
561            {
562                diagnostics.push(Diagnostic::error(
563                    None,
564                    format!("{}: shared preferences not saved: {error}", shared_path.display()),
565                ));
566            }
567            None
568        };
569        let own = Settings::open(config_dir.join(super::ecosystem::file_name(app))).member_of(self);
570        let ecosystem_value = |key: Shared| -> Option<String> {
571            let shared = shared.as_ref()?;
572            let text = key.read(shared).filter(|text| valid(key, text))?;
573            (text != self.id()).then_some(text)
574        };
575        let app_value =
576            |key: Shared| -> Option<String> { key.read(&own).filter(|text| text != self.id() && valid(key, text)) };
577        if let Some(shared) = &shared {
578            for key in Shared::ALL {
579                if key.read(shared).is_some_and(|text| text == self.id()) {
580                    diagnostics.push(Diagnostic::warning(
581                        shared.origin(key.key()),
582                        format!(
583                            "`{}` cannot follow the ecosystem in the ecosystem's own file; the detected value is used",
584                            key.key()
585                        ),
586                    ));
587                }
588            }
589        }
590        let mut prefs = resolved_from(detected, app_value, ecosystem_value);
591        prefs.update_notice = shared.as_ref().is_none_or(super::update_notice::from_shared);
592        prefs.diagnostics = diagnostics;
593        prefs
594    }
595}
596
597/// Whether `text` is a usable value of `key`; what is not was already reported by the settings
598/// load that read it.
599fn valid(key: Shared, text: &str) -> bool {
600    match key {
601        Shared::Icons => IconMode::from_name(text).is_some(),
602        Shared::ReducedMotion => text.parse::<bool>().is_ok(),
603        Shared::Language | Shared::Theme => !text.trim().is_empty(),
604    }
605}
606
607/// Each key from the application's value, the ecosystem's value or the detected one, in that order.
608fn resolved_from(
609    detected: &Detected,
610    app_value: impl Fn(Shared) -> Option<String>,
611    ecosystem_value: impl Fn(Shared) -> Option<String>,
612) -> Preferences {
613    let text = |key: Shared| -> Resolved<String> {
614        if let Some(value) = app_value(key) {
615            Resolved { value, source: Source::App }
616        } else if let Some(value) = ecosystem_value(key) {
617            Resolved { value, source: Source::Ecosystem }
618        } else {
619            Resolved { value: detected.text(key), source: Source::Detected }
620        }
621    };
622    let icons = text(Shared::Icons);
623    let reduced = text(Shared::ReducedMotion);
624    Preferences {
625        language: text(Shared::Language),
626        theme: text(Shared::Theme),
627        icons: Resolved { value: IconMode::from_name(&icons.value).unwrap_or(detected.icons), source: icons.source },
628        reduced_motion: Resolved { value: reduced.value.parse().unwrap_or(false), source: reduced.source },
629        update_notice: true,
630        diagnostics: Vec::new(),
631    }
632}
633
634/// Holds the ecosystem's folder with an advisory lock while it lives, so no other writer reads a file
635/// between this writer's reading and writing it. Locking the folder rather than a file of its own
636/// leaves nothing behind in the user's settings. Unix only: elsewhere the framework has no
637/// advisory lock, as for [`AppLock`](super::AppLock).
638#[cfg(unix)]
639pub(super) fn hold_folder(dir: &Path) -> io::Result<Option<fs::File>> {
640    let folder = fs::File::open(dir)?;
641    folder.lock()?;
642    Ok(Some(folder))
643}
644
645#[cfg(not(unix))]
646pub(super) fn hold_folder(_dir: &Path) -> io::Result<Option<fs::File>> {
647    Ok(None)
648}
649
650/// Writes the first shared file, creating its folder.
651fn create(path: &Path, text: &str) -> io::Result<()> {
652    if let Some(dir) = path.parent() {
653        fs::create_dir_all(dir)?;
654    }
655    atomic_write(path, text.as_bytes())
656}
657
658/// Reads the file at `path` as it is on disk now, changes only `key` to `value` and writes it
659/// back. `ecosystem` marks an application's file, whose own values follow the ecosystem.
660pub(super) fn rewrite(path: &Path, key: &str, value: SettingValue, ecosystem: Option<&Ecosystem>) -> io::Result<()> {
661    let mut settings = Settings::open(path);
662    if let Some(ecosystem) = ecosystem {
663        settings = settings.member_of(ecosystem);
664    }
665    if settings.value(key) == Some(&value) && path.exists() {
666        return Ok(());
667    }
668    settings.store(key, value);
669    settings.save()
670}
671
672#[cfg(test)]
673#[path = "preferences_tests.rs"]
674mod tests;