Skip to main content

cadmus_core/settings/
mod.rs

1mod preset;
2pub mod versioned;
3
4use crate::color::{BLACK, Color};
5use crate::device::CURRENT_DEVICE;
6use crate::fl;
7use crate::frontlight::{LightLevel, LightLevels};
8use crate::geolocation::Coordinates;
9use crate::i18n::I18nDisplay;
10use crate::metadata::{SortMethod, TextAlign};
11use crate::unit::mm_to_px;
12use fxhash::FxHashSet;
13use sqlx::encode::IsNull;
14use sqlx::error::BoxDynError;
15use sqlx::sqlite::{Sqlite, SqliteArgumentsBuffer, SqliteTypeInfo, SqliteValueRef};
16use unic_langid::LanguageIdentifier;
17
18pub use self::preset::{LightPreset, guess_frontlight};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, HashMap};
21use std::env;
22use std::fmt::{self, Debug};
23use std::ops::{Index, IndexMut};
24use std::path::PathBuf;
25
26pub const SETTINGS_PATH: &str = "Settings.toml";
27pub const DEFAULT_FONT_PATH: &str = "/mnt/onboard/fonts";
28pub const INTERNAL_CARD_ROOT: &str = "/mnt/onboard";
29pub const EXTERNAL_CARD_ROOT: &str = "/mnt/sd";
30const LOGO_SPECIAL_PATH: &str = "logo:";
31const COVER_SPECIAL_PATH: &str = "cover:";
32const CALENDAR_SPECIAL_PATH: &str = "calendar:";
33const BLANK_SPECIAL_PATH: &str = "blank:";
34const BLANK_INVERTED_SPECIAL_PATH: &str = "blank-inverted:";
35
36/// How to display intermission screens.
37/// Logo, Cover, Calendar, Blank and BlankInverted are special values that map
38/// to built-in displays.
39#[derive(Debug, Clone, Eq, PartialEq)]
40pub enum IntermissionDisplay {
41    /// Display the built-in logo image.
42    Logo,
43    /// Display the cover of the currently reading book.
44    Cover,
45    /// Display the built-in calendar view.
46    Calendar,
47    /// Display a blank white screen.
48    Blank,
49    /// Display a blank black screen.
50    BlankInverted,
51    /// Display a custom image from the given path.
52    Image(PathBuf),
53}
54
55impl Serialize for IntermissionDisplay {
56    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
57    where
58        S: serde::Serializer,
59    {
60        match self {
61            IntermissionDisplay::Logo => serializer.serialize_str(LOGO_SPECIAL_PATH),
62            IntermissionDisplay::Cover => serializer.serialize_str(COVER_SPECIAL_PATH),
63            IntermissionDisplay::Calendar => serializer.serialize_str(CALENDAR_SPECIAL_PATH),
64            IntermissionDisplay::Blank => serializer.serialize_str(BLANK_SPECIAL_PATH),
65            IntermissionDisplay::BlankInverted => {
66                serializer.serialize_str(BLANK_INVERTED_SPECIAL_PATH)
67            }
68            IntermissionDisplay::Image(path) => {
69                serializer.serialize_str(path.to_string_lossy().as_ref())
70            }
71        }
72    }
73}
74
75impl<'de> Deserialize<'de> for IntermissionDisplay {
76    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
77    where
78        D: serde::Deserializer<'de>,
79    {
80        let s = String::deserialize(deserializer)?;
81        Ok(match s.as_str() {
82            LOGO_SPECIAL_PATH => IntermissionDisplay::Logo,
83            COVER_SPECIAL_PATH => IntermissionDisplay::Cover,
84            CALENDAR_SPECIAL_PATH => IntermissionDisplay::Calendar,
85            BLANK_SPECIAL_PATH => IntermissionDisplay::Blank,
86            BLANK_INVERTED_SPECIAL_PATH => IntermissionDisplay::BlankInverted,
87            _ => IntermissionDisplay::Image(PathBuf::from(s)),
88        })
89    }
90}
91
92impl fmt::Display for IntermissionDisplay {
93    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
94        match self {
95            IntermissionDisplay::Logo => write!(f, "Logo"),
96            IntermissionDisplay::Cover => write!(f, "Cover"),
97            IntermissionDisplay::Calendar => write!(f, "Calendar"),
98            IntermissionDisplay::Blank => write!(f, "Blank"),
99            IntermissionDisplay::BlankInverted => write!(f, "Blank Inverted"),
100            IntermissionDisplay::Image(_) => write!(f, "Custom"),
101        }
102    }
103}
104
105impl IntermissionDisplay {
106    /// Returns whether this display mode is supported for the given intermission kind.
107    pub fn is_supported_for(&self, kind: IntermKind) -> bool {
108        if !matches!(self, IntermissionDisplay::Calendar) {
109            return true;
110        }
111
112        kind.supports_calendar()
113    }
114}
115
116// Default font size in points.
117pub const DEFAULT_FONT_SIZE: f32 = 11.0;
118// Default margin width in millimeters.
119pub const DEFAULT_MARGIN_WIDTH: i32 = 8;
120// Default line height in ems.
121pub const DEFAULT_LINE_HEIGHT: f32 = 1.2;
122// Default font family name.
123pub const DEFAULT_FONT_FAMILY: &str = "Libertinus Serif";
124// Default text alignment.
125pub const DEFAULT_TEXT_ALIGN: TextAlign = TextAlign::Left;
126pub const HYPHEN_PENALTY: i32 = 50;
127pub const STRETCH_TOLERANCE: f32 = 1.26;
128
129#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
130#[serde(rename_all = "kebab-case")]
131pub enum RotationLock {
132    Landscape,
133    Portrait,
134    Current,
135}
136
137#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
138#[serde(rename_all = "kebab-case")]
139pub enum ButtonScheme {
140    Natural,
141    Inverted,
142}
143
144impl fmt::Display for ButtonScheme {
145    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
146        Debug::fmt(self, f)
147    }
148}
149
150impl I18nDisplay for ButtonScheme {
151    fn to_i18n_string(&self) -> String {
152        match self {
153            ButtonScheme::Natural => fl!("settings-button-scheme-natural"),
154            ButtonScheme::Inverted => fl!("settings-button-scheme-inverted"),
155        }
156    }
157}
158
159#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
160#[serde(rename_all = "kebab-case")]
161pub enum StartupMode {
162    #[default]
163    Home,
164    LastFile,
165}
166
167impl fmt::Display for StartupMode {
168    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
169        match self {
170            StartupMode::Home => write!(f, "Home"),
171            StartupMode::LastFile => write!(f, "Last File"),
172        }
173    }
174}
175
176impl I18nDisplay for StartupMode {
177    fn to_i18n_string(&self) -> String {
178        match self {
179            StartupMode::Home => fl!("settings-startup-mode-home"),
180            StartupMode::LastFile => fl!("settings-startup-mode-last-file"),
181        }
182    }
183}
184
185#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
186#[serde(rename_all = "kebab-case")]
187pub enum IntermKind {
188    Suspend,
189    PowerOff,
190    Share,
191}
192
193impl IntermKind {
194    pub fn text(&self) -> &str {
195        match self {
196            IntermKind::Suspend => "Sleeping",
197            IntermKind::PowerOff => "Powered off",
198            IntermKind::Share => "Shared",
199        }
200    }
201
202    pub fn supports_calendar(self) -> bool {
203        matches!(self, IntermKind::Suspend)
204    }
205}
206
207/// Configuration for intermission screen displays.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "kebab-case")]
210pub struct Intermissions {
211    suspend: IntermissionDisplay,
212    power_off: IntermissionDisplay,
213    share: IntermissionDisplay,
214}
215
216impl Index<IntermKind> for Intermissions {
217    type Output = IntermissionDisplay;
218
219    fn index(&self, key: IntermKind) -> &Self::Output {
220        match key {
221            IntermKind::Suspend => &self.suspend,
222            IntermKind::PowerOff => &self.power_off,
223            IntermKind::Share => &self.share,
224        }
225    }
226}
227
228impl IndexMut<IntermKind> for Intermissions {
229    fn index_mut(&mut self, key: IntermKind) -> &mut Self::Output {
230        match key {
231            IntermKind::Suspend => &mut self.suspend,
232            IntermKind::PowerOff => &mut self.power_off,
233            IntermKind::Share => &mut self.share,
234        }
235    }
236}
237
238impl Intermissions {
239    /// Updates an intermission display when the selected mode is valid for the target kind.
240    pub fn set_display(&mut self, kind: IntermKind, display: IntermissionDisplay) -> bool {
241        if !display.is_supported_for(kind) {
242            return false;
243        }
244
245        self[kind] = display;
246        true
247    }
248
249    /// Replaces unsupported intermission modes with the default logo display.
250    pub fn sanitize(&mut self) -> bool {
251        let mut changed = false;
252
253        changed |= self.sanitize_kind(IntermKind::Suspend);
254        changed |= self.sanitize_kind(IntermKind::PowerOff);
255        changed |= self.sanitize_kind(IntermKind::Share);
256
257        if changed {
258            eprintln!(
259                "ignoring unsupported calendar intermissions for power-off/share; using logo instead"
260            );
261        }
262
263        changed
264    }
265
266    fn sanitize_kind(&mut self, kind: IntermKind) -> bool {
267        if self[kind].is_supported_for(kind) {
268            return false;
269        }
270
271        self[kind] = IntermissionDisplay::Logo;
272        true
273    }
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(default, rename_all = "kebab-case")]
278pub struct Settings {
279    pub selected_library: usize,
280    pub keyboard_layout: String,
281    pub frontlight: bool,
282    pub wifi: bool,
283    pub inverted: bool,
284    pub sleep_cover: bool,
285    pub auto_share: bool,
286    pub auto_time: bool,
287    /// Whether frontlight levels should be managed automatically.
288    ///
289    /// When enabled, Cadmus derives brightness and warmth from the current
290    /// time and a known location.
291    pub auto_frontlight: bool,
292    /// The brightness to use after sunset and before sunrise when automatic
293    /// frontlight control is enabled.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub auto_frontlight_night_brightness: Option<LightLevel>,
296    /// A user-specified location for automatic frontlight calculations.
297    ///
298    /// When present, this takes precedence over coordinates discovered from
299    /// automatic time syncing.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub auto_frontlight_manual_coordinates: Option<Coordinates>,
302    /// The last automatically discovered location for automatic frontlight
303    /// calculations.
304    ///
305    /// This is typically refreshed during automatic time synchronization and
306    /// is used only when no manual coordinates are configured.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub auto_frontlight_last_coordinates: Option<Coordinates>,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub rotation_lock: Option<RotationLock>,
311    pub button_scheme: ButtonScheme,
312    pub auto_suspend: f32,
313    pub auto_power_off: f32,
314    pub time_format: String,
315    pub date_format: String,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub external_urls_queue: Option<PathBuf>,
318    #[serde(skip_serializing_if = "Vec::is_empty")]
319    pub libraries: Vec<LibrarySettings>,
320    pub intermissions: Intermissions,
321    #[serde(skip_serializing_if = "Vec::is_empty")]
322    pub frontlight_presets: Vec<LightPreset>,
323    pub home: HomeSettings,
324    pub reader: ReaderSettings,
325    pub import: ImportSettings,
326    pub dictionary: DictionarySettings,
327    pub sketch: SketchSettings,
328    pub calculator: CalculatorSettings,
329    pub battery: BatterySettings,
330    pub frontlight_levels: LightLevels,
331    pub ota: OtaSettings,
332    pub logging: LoggingSettings,
333    pub settings_retention: usize,
334    pub db_backup_retention: usize,
335    pub startup_mode: StartupMode,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub locale: Option<LanguageIdentifier>,
338}
339
340impl Settings {
341    /// Normalizes unsupported settings values loaded from disk.
342    pub fn sanitize(&mut self) -> bool {
343        self.intermissions.sanitize()
344    }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
348#[serde(default, rename_all = "kebab-case")]
349pub struct LibrarySettings {
350    pub name: String,
351    pub path: PathBuf,
352    pub sort_method: SortMethod,
353    pub first_column: FirstColumn,
354    pub second_column: SecondColumn,
355    pub thumbnail_previews: bool,
356    #[serde(skip_serializing_if = "Vec::is_empty")]
357    pub hooks: Vec<Hook>,
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub finished: Option<FinishedAction>,
360}
361
362impl Default for LibrarySettings {
363    fn default() -> Self {
364        LibrarySettings {
365            name: "Unnamed".to_string(),
366            path: env::current_dir()
367                .ok()
368                .unwrap_or_else(|| PathBuf::from("/")),
369            sort_method: SortMethod::Opened,
370            first_column: FirstColumn::TitleAndAuthor,
371            second_column: SecondColumn::Progress,
372            thumbnail_previews: true,
373            hooks: Vec::new(),
374            finished: None,
375        }
376    }
377}
378
379/// Settings controlling which files are imported into the library.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381#[serde(default, rename_all = "kebab-case")]
382pub struct ImportSettings {
383    pub sync_metadata: bool,
384    pub metadata_kinds: FxHashSet<String>,
385    #[serde(deserialize_with = "deserialize_file_extension_set")]
386    pub allowed_kinds: FxHashSet<FileExtension>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
390#[serde(default, rename_all = "kebab-case")]
391pub struct DictionarySettings {
392    pub margin_width: i32,
393    pub font_size: f32,
394    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
395    pub languages: BTreeMap<String, Vec<String>>,
396}
397
398impl Default for DictionarySettings {
399    fn default() -> Self {
400        DictionarySettings {
401            font_size: 11.0,
402            margin_width: 4,
403            languages: BTreeMap::new(),
404        }
405    }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
409#[serde(default, rename_all = "kebab-case")]
410pub struct SketchSettings {
411    pub save_path: PathBuf,
412    pub notify_success: bool,
413    pub pen: Pen,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
417#[serde(default, rename_all = "kebab-case")]
418pub struct CalculatorSettings {
419    pub font_size: f32,
420    pub margin_width: i32,
421    pub history_size: usize,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
425#[serde(default, rename_all = "kebab-case")]
426pub struct Pen {
427    pub size: i32,
428    pub color: Color,
429    pub dynamic: bool,
430    pub amplitude: f32,
431    pub min_speed: f32,
432    pub max_speed: f32,
433}
434
435impl Default for Pen {
436    fn default() -> Self {
437        Pen {
438            size: 2,
439            color: BLACK,
440            dynamic: true,
441            amplitude: 4.0,
442            min_speed: 0.0,
443            max_speed: mm_to_px(254.0, CURRENT_DEVICE.dpi),
444        }
445    }
446}
447
448impl Default for SketchSettings {
449    fn default() -> Self {
450        SketchSettings {
451            save_path: PathBuf::from("Sketches"),
452            notify_success: true,
453            pen: Pen::default(),
454        }
455    }
456}
457
458impl Default for CalculatorSettings {
459    fn default() -> Self {
460        CalculatorSettings {
461            font_size: 8.0,
462            margin_width: 2,
463            history_size: 4096,
464        }
465    }
466}
467
468#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
469#[serde(rename_all = "kebab-case")]
470pub struct Columns {
471    first: FirstColumn,
472    second: SecondColumn,
473}
474
475#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
476#[serde(rename_all = "kebab-case")]
477pub enum FirstColumn {
478    TitleAndAuthor,
479    FileName,
480}
481
482#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
483#[serde(rename_all = "kebab-case")]
484pub enum SecondColumn {
485    Progress,
486    Year,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize)]
490#[serde(default, rename_all = "kebab-case")]
491pub struct Hook {
492    pub path: PathBuf,
493    pub program: PathBuf,
494    pub sort_method: Option<SortMethod>,
495    pub first_column: Option<FirstColumn>,
496    pub second_column: Option<SecondColumn>,
497}
498
499impl Default for Hook {
500    fn default() -> Self {
501        Hook {
502            path: PathBuf::default(),
503            program: PathBuf::default(),
504            sort_method: None,
505            first_column: None,
506            second_column: None,
507        }
508    }
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
512#[serde(default, rename_all = "kebab-case")]
513pub struct HomeSettings {
514    pub address_bar: bool,
515    pub navigation_bar: bool,
516    pub max_levels: usize,
517    pub max_trash_size: u64,
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
521#[serde(default, rename_all = "kebab-case")]
522pub struct RefreshRateSettings {
523    #[serde(flatten)]
524    pub global: RefreshRatePair,
525    #[serde(skip_serializing_if = "HashMap::is_empty")]
526    pub by_kind: HashMap<String, RefreshRatePair>,
527}
528
529/// A known file extension for which per-kind refresh rates can be configured.
530///
531/// The serialized string (e.g. `"epub"`, `"cbz"`) is used as the key in
532/// [`RefreshRateSettings::by_kind`] and as values in [`ImportSettings::allowed_kinds`].
533#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
534#[serde(rename_all = "lowercase")]
535pub enum FileExtension {
536    /// Comic book RAR archive.
537    Cbr,
538    /// Comic book ZIP archive.
539    Cbz,
540    /// DjVu document.
541    Djvu,
542    /// EPUB ebook.
543    Epub,
544    /// FictionBook document.
545    Fb2,
546    /// HTML document.
547    Html,
548    /// JPEG image using the long extension.
549    Jpeg,
550    /// JPEG image using the short extension.
551    Jpg,
552    /// Mobipocket ebook.
553    Mobi,
554    /// OpenXPS document.
555    Oxps,
556    /// PDF document.
557    Pdf,
558    /// PNG image.
559    Png,
560    /// SVG image.
561    Svg,
562    /// Plain text document.
563    Txt,
564    /// WebP image.
565    Webp,
566    /// XPS document.
567    Xps,
568}
569
570impl FileExtension {
571    /// Returns all known file extensions.
572    pub fn all() -> &'static [FileExtension] {
573        &[
574            FileExtension::Cbr,
575            FileExtension::Cbz,
576            FileExtension::Djvu,
577            FileExtension::Epub,
578            FileExtension::Fb2,
579            FileExtension::Html,
580            FileExtension::Jpeg,
581            FileExtension::Jpg,
582            FileExtension::Mobi,
583            FileExtension::Oxps,
584            FileExtension::Pdf,
585            FileExtension::Png,
586            FileExtension::Svg,
587            FileExtension::Txt,
588            FileExtension::Webp,
589            FileExtension::Xps,
590        ]
591    }
592
593    /// Returns the lowercase string representation used as the TOML key.
594    pub fn as_str(self) -> &'static str {
595        match self {
596            FileExtension::Cbr => "cbr",
597            FileExtension::Cbz => "cbz",
598            FileExtension::Djvu => "djvu",
599            FileExtension::Epub => "epub",
600            FileExtension::Fb2 => "fb2",
601            FileExtension::Html => "html",
602            FileExtension::Jpeg => "jpeg",
603            FileExtension::Jpg => "jpg",
604            FileExtension::Mobi => "mobi",
605            FileExtension::Oxps => "oxps",
606            FileExtension::Pdf => "pdf",
607            FileExtension::Png => "png",
608            FileExtension::Svg => "svg",
609            FileExtension::Txt => "txt",
610            FileExtension::Webp => "webp",
611            FileExtension::Xps => "xps",
612        }
613    }
614}
615
616/// Error returned when a string does not match any known file extension.
617#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
618#[error("unknown file extension: {0}")]
619pub struct UnknownFileExtension(
620    /// Extension string that could not be parsed.
621    pub String,
622);
623
624impl std::str::FromStr for FileExtension {
625    type Err = UnknownFileExtension;
626
627    fn from_str(s: &str) -> Result<Self, Self::Err> {
628        match s {
629            "cbr" => Ok(FileExtension::Cbr),
630            "cbz" => Ok(FileExtension::Cbz),
631            "djvu" => Ok(FileExtension::Djvu),
632            "epub" => Ok(FileExtension::Epub),
633            "fb2" => Ok(FileExtension::Fb2),
634            "html" | "htm" => Ok(FileExtension::Html),
635            "jpeg" => Ok(FileExtension::Jpeg),
636            "jpg" => Ok(FileExtension::Jpg),
637            "mobi" => Ok(FileExtension::Mobi),
638            "oxps" => Ok(FileExtension::Oxps),
639            "pdf" => Ok(FileExtension::Pdf),
640            "png" => Ok(FileExtension::Png),
641            "svg" => Ok(FileExtension::Svg),
642            "txt" => Ok(FileExtension::Txt),
643            "webp" => Ok(FileExtension::Webp),
644            "xps" => Ok(FileExtension::Xps),
645            _ => Err(UnknownFileExtension(s.to_owned())),
646        }
647    }
648}
649
650impl sqlx::Type<Sqlite> for FileExtension {
651    fn type_info() -> SqliteTypeInfo {
652        <String as sqlx::Type<Sqlite>>::type_info()
653    }
654
655    fn compatible(ty: &SqliteTypeInfo) -> bool {
656        <String as sqlx::Type<Sqlite>>::compatible(ty)
657    }
658}
659
660impl sqlx::Encode<'_, Sqlite> for FileExtension {
661    fn encode_by_ref(&self, buf: &mut SqliteArgumentsBuffer) -> Result<IsNull, BoxDynError> {
662        self.as_str().encode_by_ref(buf)
663    }
664}
665
666impl<'r> sqlx::Decode<'r, Sqlite> for FileExtension {
667    fn decode(value: SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
668        let s = <String as sqlx::Decode<'r, Sqlite>>::decode(value)?;
669        s.parse()
670            .map_err(|UnknownFileExtension(ext)| format!("unknown file extension: {ext}").into())
671    }
672}
673
674fn deserialize_file_extension_set<'de, D>(
675    deserializer: D,
676) -> Result<FxHashSet<FileExtension>, D::Error>
677where
678    D: serde::Deserializer<'de>,
679{
680    struct FileExtensionSetVisitor;
681
682    impl<'de> serde::de::Visitor<'de> for FileExtensionSetVisitor {
683        type Value = FxHashSet<FileExtension>;
684
685        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
686            formatter.write_str("a sequence of file extension strings")
687        }
688
689        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
690        where
691            A: serde::de::SeqAccess<'de>,
692        {
693            let mut set = FxHashSet::default();
694
695            while let Some(s) = seq.next_element::<String>()? {
696                match s.parse::<FileExtension>() {
697                    Ok(ext) => {
698                        set.insert(ext);
699                    }
700                    Err(e) => {
701                        tracing::warn!(extension = %s, error = %e, "failed to load extension");
702                    }
703                }
704            }
705
706            Ok(set)
707        }
708    }
709
710    deserializer.deserialize_seq(FileExtensionSetVisitor)
711}
712
713impl fmt::Display for FileExtension {
714    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
715        write!(f, "{}", self.as_str())
716    }
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize)]
720#[serde(rename_all = "kebab-case")]
721pub struct RefreshRatePair {
722    pub regular: u8,
723    pub inverted: u8,
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize)]
727#[serde(default, rename_all = "kebab-case")]
728pub struct ReaderSettings {
729    pub finished: FinishedAction,
730    pub south_east_corner: SouthEastCornerAction,
731    pub bottom_right_gesture: BottomRightGestureAction,
732    pub south_strip: SouthStripAction,
733    pub west_strip: WestStripAction,
734    pub east_strip: EastStripAction,
735    pub strip_width: f32,
736    pub corner_width: f32,
737    pub font_path: String,
738    pub font_family: String,
739    pub font_size: f32,
740    pub min_font_size: f32,
741    pub max_font_size: f32,
742    pub text_align: TextAlign,
743    pub margin_width: i32,
744    pub min_margin_width: i32,
745    pub max_margin_width: i32,
746    pub line_height: f32,
747    pub continuous_fit_to_width: bool,
748    pub ignore_document_css: bool,
749    #[serde(deserialize_with = "deserialize_file_extension_set")]
750    pub dithered_kinds: FxHashSet<FileExtension>,
751    pub paragraph_breaker: ParagraphBreakerSettings,
752    pub refresh_rate: RefreshRateSettings,
753}
754
755#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
756#[serde(default, rename_all = "kebab-case")]
757pub struct ParagraphBreakerSettings {
758    pub hyphen_penalty: i32,
759    pub stretch_tolerance: f32,
760}
761
762#[derive(Debug, Clone, Serialize, Deserialize)]
763#[serde(default, rename_all = "kebab-case")]
764pub struct BatterySettings {
765    pub warn: f32,
766    pub power_off: f32,
767}
768
769/// Configures structured logging to disk and optional OTLP export.
770#[derive(Debug, Clone, Serialize, Deserialize)]
771#[serde(default, rename_all = "kebab-case")]
772pub struct LoggingSettings {
773    /// Enables logging output when set to true.
774    pub enabled: bool,
775    /// Minimum log level to record (for example: "info", "debug").
776    pub level: String,
777    /// Maximum number of rotated log files to keep.
778    pub max_files: usize,
779    /// Directory where JSON log files are written.
780    pub directory: PathBuf,
781    /// Optional OTLP endpoint; env vars override this value.
782    #[serde(skip_serializing_if = "Option::is_none")]
783    pub otlp_endpoint: Option<String>,
784    /// Optional Pyroscope server URL for continuous profiling; env vars override this value.
785    #[serde(skip_serializing_if = "Option::is_none")]
786    pub pyroscope_endpoint: Option<String>,
787    /// Captures kernel logs via logread if kernel log capture is supported.
788    pub enable_kern_log: bool,
789    /// Captures D-Bus signals via the in-process zbus DbusMonitorTask when D-Bus log capture is supported.
790    pub enable_dbus_log: bool,
791}
792
793/// OTA update settings.
794///
795/// Authentication is handled via GitHub device auth flow — no token configuration
796/// is required in `Settings.toml`. The token is obtained interactively and
797/// persisted to disk by the application.
798#[derive(Debug, Clone, Default, Serialize, Deserialize)]
799#[serde(default, rename_all = "kebab-case")]
800pub struct OtaSettings {}
801
802#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
803#[serde(rename_all = "kebab-case")]
804pub enum FinishedAction {
805    Notify,
806    Close,
807    GoToNext,
808}
809
810impl fmt::Display for FinishedAction {
811    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
812        match self {
813            FinishedAction::Notify => write!(f, "Notify"),
814            FinishedAction::Close => write!(f, "Close"),
815            FinishedAction::GoToNext => write!(f, "Go to Next"),
816        }
817    }
818}
819
820impl I18nDisplay for FinishedAction {
821    fn to_i18n_string(&self) -> String {
822        match self {
823            FinishedAction::Notify => fl!("settings-finished-action-notify"),
824            FinishedAction::Close => fl!("settings-finished-action-close"),
825            FinishedAction::GoToNext => fl!("settings-finished-action-goto-next"),
826        }
827    }
828}
829
830#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
831#[serde(rename_all = "kebab-case")]
832pub enum SouthEastCornerAction {
833    NextPage,
834    GoToPage,
835}
836
837#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
838#[serde(rename_all = "kebab-case")]
839pub enum BottomRightGestureAction {
840    ToggleDithered,
841    ToggleInverted,
842}
843
844#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
845#[serde(rename_all = "kebab-case")]
846pub enum SouthStripAction {
847    ToggleBars,
848    NextPage,
849}
850
851#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
852#[serde(rename_all = "kebab-case")]
853pub enum EastStripAction {
854    PreviousPage,
855    NextPage,
856    None,
857}
858
859#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
860#[serde(rename_all = "kebab-case")]
861pub enum WestStripAction {
862    PreviousPage,
863    NextPage,
864    None,
865}
866
867impl Default for RefreshRateSettings {
868    fn default() -> Self {
869        RefreshRateSettings {
870            global: RefreshRatePair {
871                regular: 8,
872                inverted: 2,
873            },
874            by_kind: HashMap::new(),
875        }
876    }
877}
878
879impl Default for HomeSettings {
880    fn default() -> Self {
881        HomeSettings {
882            address_bar: false,
883            navigation_bar: true,
884            max_levels: 3,
885            max_trash_size: 32 * (1 << 20),
886        }
887    }
888}
889
890impl Default for ParagraphBreakerSettings {
891    fn default() -> Self {
892        ParagraphBreakerSettings {
893            hyphen_penalty: HYPHEN_PENALTY,
894            stretch_tolerance: STRETCH_TOLERANCE,
895        }
896    }
897}
898
899impl Default for ReaderSettings {
900    fn default() -> Self {
901        ReaderSettings {
902            finished: FinishedAction::Close,
903            south_east_corner: SouthEastCornerAction::GoToPage,
904            bottom_right_gesture: BottomRightGestureAction::ToggleDithered,
905            south_strip: SouthStripAction::ToggleBars,
906            west_strip: WestStripAction::PreviousPage,
907            east_strip: EastStripAction::NextPage,
908            strip_width: 0.6,
909            corner_width: 0.4,
910            font_path: DEFAULT_FONT_PATH.to_string(),
911            font_family: DEFAULT_FONT_FAMILY.to_string(),
912            font_size: DEFAULT_FONT_SIZE,
913            min_font_size: DEFAULT_FONT_SIZE / 2.0,
914            max_font_size: 3.0 * DEFAULT_FONT_SIZE / 2.0,
915            text_align: DEFAULT_TEXT_ALIGN,
916            margin_width: DEFAULT_MARGIN_WIDTH,
917            min_margin_width: DEFAULT_MARGIN_WIDTH.saturating_sub(8),
918            max_margin_width: DEFAULT_MARGIN_WIDTH.saturating_add(2),
919            line_height: DEFAULT_LINE_HEIGHT,
920            continuous_fit_to_width: true,
921            ignore_document_css: false,
922            dithered_kinds: [
923                FileExtension::Cbz,
924                FileExtension::Png,
925                FileExtension::Jpg,
926                FileExtension::Jpeg,
927                FileExtension::Webp,
928            ]
929            .iter()
930            .copied()
931            .collect(),
932            paragraph_breaker: ParagraphBreakerSettings::default(),
933            refresh_rate: RefreshRateSettings::default(),
934        }
935    }
936}
937
938impl Default for ImportSettings {
939    fn default() -> Self {
940        ImportSettings {
941            sync_metadata: true,
942            metadata_kinds: ["epub", "pdf", "djvu"]
943                .iter()
944                .map(|k| k.to_string())
945                .collect(),
946            allowed_kinds: [
947                FileExtension::Pdf,
948                FileExtension::Djvu,
949                FileExtension::Epub,
950                FileExtension::Fb2,
951                FileExtension::Txt,
952                FileExtension::Xps,
953                FileExtension::Oxps,
954                FileExtension::Mobi,
955                FileExtension::Cbz,
956                FileExtension::Webp,
957                FileExtension::Png,
958                FileExtension::Jpg,
959                FileExtension::Jpeg,
960            ]
961            .iter()
962            .copied()
963            .collect(),
964        }
965    }
966}
967
968impl ImportSettings {
969    /// Returns `true` if `kind` is in the set of allowed file kinds.
970    pub fn is_kind_allowed(&self, kind: FileExtension) -> bool {
971        self.allowed_kinds.contains(&kind)
972    }
973}
974
975impl Default for BatterySettings {
976    fn default() -> Self {
977        BatterySettings {
978            warn: 10.0,
979            power_off: 3.0,
980        }
981    }
982}
983
984impl Default for LoggingSettings {
985    fn default() -> Self {
986        LoggingSettings {
987            enabled: true,
988            level: "info".to_string(),
989            max_files: 3,
990            directory: PathBuf::from("logs"),
991            otlp_endpoint: None,
992            pyroscope_endpoint: None,
993            enable_kern_log: false,
994            enable_dbus_log: false,
995        }
996    }
997}
998
999impl Default for Settings {
1000    fn default() -> Self {
1001        Settings {
1002            selected_library: 0,
1003            libraries: cfg_select! {
1004                feature = "emulator" => {
1005                    vec![LibrarySettings {
1006                        name: "Cadmus Source".to_string(),
1007                        path: PathBuf::from("."),
1008                        ..Default::default()
1009                    }]
1010                }
1011                _ => {
1012                    vec![
1013                        LibrarySettings {
1014                            name: "On Board".to_string(),
1015                            path: PathBuf::from(INTERNAL_CARD_ROOT),
1016                            hooks: vec![Hook {
1017                                path: PathBuf::from("Articles"),
1018                                program: PathBuf::from("bin/article_fetcher/article_fetcher"),
1019                                sort_method: Some(SortMethod::Added),
1020                                first_column: Some(FirstColumn::TitleAndAuthor),
1021                                second_column: Some(SecondColumn::Progress),
1022                            }],
1023                            ..Default::default()
1024                        },
1025                        LibrarySettings {
1026                            name: "Removable".to_string(),
1027                            path: PathBuf::from(EXTERNAL_CARD_ROOT),
1028                            ..Default::default()
1029                        },
1030                        LibrarySettings {
1031                            name: "Dropbox".to_string(),
1032                            path: PathBuf::from("/mnt/onboard/.kobo/dropbox"),
1033                            ..Default::default()
1034                        },
1035                        LibrarySettings {
1036                            name: "KePub".to_string(),
1037                            path: PathBuf::from("/mnt/onboard/.kobo/kepub"),
1038                            ..Default::default()
1039                        },
1040                    ]
1041                }
1042            },
1043            external_urls_queue: Some(PathBuf::from("bin/article_fetcher/urls.txt")),
1044            keyboard_layout: "English".to_string(),
1045            frontlight: true,
1046            wifi: false,
1047            inverted: false,
1048            sleep_cover: true,
1049            auto_share: false,
1050            auto_time: false,
1051            auto_frontlight: false,
1052            auto_frontlight_night_brightness: Some(LightLevel::default()),
1053            auto_frontlight_manual_coordinates: None,
1054            auto_frontlight_last_coordinates: None,
1055            rotation_lock: None,
1056            button_scheme: ButtonScheme::Natural,
1057            auto_suspend: 30.0,
1058            auto_power_off: 3.0,
1059            time_format: "%H:%M".to_string(),
1060            date_format: "%A, %B %-d, %Y".to_string(),
1061            intermissions: Intermissions {
1062                suspend: IntermissionDisplay::Logo,
1063                power_off: IntermissionDisplay::Logo,
1064                share: IntermissionDisplay::Logo,
1065            },
1066            home: HomeSettings::default(),
1067            reader: ReaderSettings::default(),
1068            import: ImportSettings::default(),
1069            dictionary: DictionarySettings::default(),
1070            sketch: SketchSettings::default(),
1071            calculator: CalculatorSettings::default(),
1072            battery: BatterySettings::default(),
1073            frontlight_levels: LightLevels::default(),
1074            frontlight_presets: Vec::new(),
1075            ota: OtaSettings::default(),
1076            logging: LoggingSettings::default(),
1077            settings_retention: 3,
1078            db_backup_retention: 2,
1079            startup_mode: StartupMode::default(),
1080            locale: None,
1081        }
1082    }
1083}
1084
1085/// Returns the coordinates to use for automatic frontlight calculations.
1086///
1087/// Manual coordinates take precedence over the last automatically discovered
1088/// location.
1089pub fn resolve_coordinates(settings: &Settings) -> Option<Coordinates> {
1090    settings
1091        .auto_frontlight_manual_coordinates
1092        .or(settings.auto_frontlight_last_coordinates)
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097    use super::*;
1098
1099    #[test]
1100    fn test_ota_settings_serializes_empty() {
1101        let settings = OtaSettings::default();
1102        let serialized = toml::to_string(&settings).expect("Failed to serialize");
1103        assert!(
1104            serialized.is_empty(),
1105            "OtaSettings should serialize to an empty string"
1106        );
1107    }
1108
1109    #[test]
1110    fn test_intermissions_struct_serialization() {
1111        let intermissions = Intermissions {
1112            suspend: IntermissionDisplay::Blank,
1113            power_off: IntermissionDisplay::BlankInverted,
1114            share: IntermissionDisplay::Image(PathBuf::from("/custom/share.png")),
1115        };
1116
1117        let serialized = toml::to_string(&intermissions).expect("Failed to serialize");
1118
1119        assert!(
1120            serialized.contains("blank:"),
1121            "Should contain blank: for suspend"
1122        );
1123        assert!(
1124            serialized.contains("blank-inverted:"),
1125            "Should contain blank-inverted: for power-off"
1126        );
1127        assert!(
1128            serialized.contains("/custom/share.png"),
1129            "Should contain custom path for share"
1130        );
1131    }
1132
1133    #[test]
1134    fn test_intermissions_struct_deserialization() {
1135        let toml_str = r#"
1136suspend = "blank:"
1137power-off = "blank-inverted:"
1138share = "/path/to/custom.png"
1139"#;
1140
1141        let intermissions: Intermissions = toml::from_str(toml_str).expect("Failed to deserialize");
1142
1143        assert!(
1144            matches!(intermissions.suspend, IntermissionDisplay::Blank),
1145            "suspend should deserialize to Blank"
1146        );
1147        assert!(
1148            matches!(intermissions.power_off, IntermissionDisplay::BlankInverted),
1149            "power_off should deserialize to BlankInverted"
1150        );
1151        assert!(
1152            matches!(
1153                intermissions.share,
1154                IntermissionDisplay::Image(ref path) if path == &PathBuf::from("/path/to/custom.png")
1155            ),
1156            "share should deserialize to Image with correct path"
1157        );
1158    }
1159
1160    #[test]
1161    fn test_intermissions_struct_round_trip() {
1162        let original = Intermissions {
1163            suspend: IntermissionDisplay::Blank,
1164            power_off: IntermissionDisplay::BlankInverted,
1165            share: IntermissionDisplay::Image(PathBuf::from("/some/custom/image.jpg")),
1166        };
1167
1168        let serialized = toml::to_string(&original).expect("Failed to serialize");
1169        let deserialized: Intermissions =
1170            toml::from_str(&serialized).expect("Failed to deserialize");
1171
1172        assert_eq!(
1173            original.suspend, deserialized.suspend,
1174            "suspend should survive round trip"
1175        );
1176        assert_eq!(
1177            original.power_off, deserialized.power_off,
1178            "power_off should survive round trip"
1179        );
1180        assert_eq!(
1181            original.share, deserialized.share,
1182            "share should survive round trip"
1183        );
1184    }
1185
1186    #[test]
1187    fn test_intermissions_reject_unsupported_calendar_selection() {
1188        let mut intermissions = Intermissions {
1189            suspend: IntermissionDisplay::Logo,
1190            power_off: IntermissionDisplay::Logo,
1191            share: IntermissionDisplay::Logo,
1192        };
1193
1194        assert!(!intermissions.set_display(IntermKind::PowerOff, IntermissionDisplay::Calendar));
1195        assert!(!intermissions.set_display(IntermKind::Share, IntermissionDisplay::Calendar));
1196        assert!(intermissions.set_display(IntermKind::Suspend, IntermissionDisplay::Calendar));
1197
1198        assert_eq!(
1199            intermissions[IntermKind::PowerOff],
1200            IntermissionDisplay::Logo
1201        );
1202        assert_eq!(intermissions[IntermKind::Share], IntermissionDisplay::Logo);
1203        assert_eq!(
1204            intermissions[IntermKind::Suspend],
1205            IntermissionDisplay::Calendar
1206        );
1207    }
1208
1209    #[test]
1210    fn test_intermissions_accept_blank_selection_for_all_kinds() {
1211        let mut intermissions = Intermissions {
1212            suspend: IntermissionDisplay::Logo,
1213            power_off: IntermissionDisplay::Logo,
1214            share: IntermissionDisplay::Logo,
1215        };
1216
1217        assert!(intermissions.set_display(IntermKind::Suspend, IntermissionDisplay::Blank));
1218        assert!(
1219            intermissions.set_display(IntermKind::PowerOff, IntermissionDisplay::BlankInverted)
1220        );
1221        assert!(intermissions.set_display(IntermKind::Share, IntermissionDisplay::Blank));
1222
1223        assert_eq!(
1224            intermissions[IntermKind::Suspend],
1225            IntermissionDisplay::Blank
1226        );
1227        assert_eq!(
1228            intermissions[IntermKind::PowerOff],
1229            IntermissionDisplay::BlankInverted
1230        );
1231        assert_eq!(intermissions[IntermKind::Share], IntermissionDisplay::Blank);
1232    }
1233
1234    #[test]
1235    fn test_intermissions_sanitize_replaces_unsupported_calendar() {
1236        let mut intermissions = Intermissions {
1237            suspend: IntermissionDisplay::Calendar,
1238            power_off: IntermissionDisplay::Calendar,
1239            share: IntermissionDisplay::Calendar,
1240        };
1241
1242        assert!(intermissions.sanitize());
1243
1244        assert_eq!(
1245            intermissions[IntermKind::Suspend],
1246            IntermissionDisplay::Calendar
1247        );
1248        assert_eq!(
1249            intermissions[IntermKind::PowerOff],
1250            IntermissionDisplay::Logo
1251        );
1252        assert_eq!(intermissions[IntermKind::Share], IntermissionDisplay::Logo);
1253    }
1254
1255    #[test]
1256    fn test_allowed_kinds_deserializes_known_extensions() {
1257        let toml_str = r#"
1258sync-metadata = true
1259metadata-kinds = ["epub"]
1260allowed-kinds = ["epub", "pdf", "cbz"]
1261"#;
1262        let settings: ImportSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1263
1264        assert!(settings.allowed_kinds.contains(&FileExtension::Epub));
1265        assert!(settings.allowed_kinds.contains(&FileExtension::Pdf));
1266        assert!(settings.allowed_kinds.contains(&FileExtension::Cbz));
1267        assert_eq!(settings.allowed_kinds.len(), 3);
1268    }
1269
1270    #[test]
1271    fn test_allowed_kinds_silently_drops_unknown_extensions() {
1272        let toml_str = r#"
1273sync-metadata = true
1274metadata-kinds = []
1275allowed-kinds = ["epub", "unknown-format", "another-unknown"]
1276"#;
1277        let settings: ImportSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1278
1279        assert!(settings.allowed_kinds.contains(&FileExtension::Epub));
1280        assert_eq!(settings.allowed_kinds.len(), 1);
1281    }
1282
1283    #[test]
1284    fn test_dithered_kinds_deserializes_known_extensions() {
1285        let toml_str = r#"
1286finished = "close"
1287dithered-kinds = ["cbz", "png", "jpeg"]
1288"#;
1289        let settings: ReaderSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1290
1291        assert!(settings.dithered_kinds.contains(&FileExtension::Cbz));
1292        assert!(settings.dithered_kinds.contains(&FileExtension::Png));
1293        assert!(settings.dithered_kinds.contains(&FileExtension::Jpeg));
1294        assert_eq!(settings.dithered_kinds.len(), 3);
1295    }
1296
1297    #[test]
1298    fn test_dithered_kinds_silently_drops_unknown_extensions() {
1299        let toml_str = r#"
1300finished = "close"
1301dithered-kinds = ["cbz", "unknown-format"]
1302"#;
1303        let settings: ReaderSettings = toml::from_str(toml_str).expect("Failed to deserialize");
1304
1305        assert!(settings.dithered_kinds.contains(&FileExtension::Cbz));
1306        assert_eq!(settings.dithered_kinds.len(), 1);
1307    }
1308
1309    #[test]
1310    fn test_file_extension_round_trip_via_from_str() {
1311        for ext in FileExtension::all() {
1312            let parsed = ext.as_str().parse::<FileExtension>().ok();
1313            assert_eq!(parsed, Some(*ext), "round trip failed for {:?}", ext);
1314        }
1315    }
1316
1317    #[test]
1318    fn test_htm_extension_parses_as_html() {
1319        let parsed = "htm".parse::<FileExtension>();
1320        assert_eq!(parsed, Ok(FileExtension::Html));
1321    }
1322
1323    #[test]
1324    fn test_html_extension_still_parses() {
1325        let parsed = "html".parse::<FileExtension>();
1326        assert_eq!(parsed, Ok(FileExtension::Html));
1327    }
1328}