Skip to main content

cadmus_core/settings/
mod.rs

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