dicom_viewer/config.rs
1//! Portable-first config and path resolution.
2//!
3//! Settings live in `config.toml` next to the executable. If the exe
4//! directory is read-only (e.g. running from a CD), we fall back to a
5//! per-user data directory and remember that decision for the session.
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12const CONFIG_FILE: &str = "config.toml";
13
14/// Resolved filesystem locations for the running viewer.
15///
16/// Construct with [`Self::resolve`]. In *portable mode* the data and log
17/// directories live next to the executable; otherwise they fall back to a
18/// platform user-data directory (via the [`directories`] crate):
19///
20/// | OS | Fallback `data_dir` |
21/// |---|---|
22/// | Windows | `%APPDATA%\dicom-viewer\` |
23/// | macOS | `~/Library/Application Support/dicom-viewer/` |
24/// | Linux | `~/.local/share/dicom-viewer/` |
25#[derive(Debug, Clone)]
26pub struct Paths {
27 /// Directory containing the running executable.
28 pub exe_dir: PathBuf,
29 /// Where `config.toml`, `annotations.json`, and `logs/` are stored.
30 pub data_dir: PathBuf,
31 /// `data_dir/logs/`. Created on resolve.
32 pub logs_dir: PathBuf,
33 portable: bool,
34}
35
36impl Paths {
37 /// Resolve paths for the current process.
38 ///
39 /// Tries to write a probe file into the exe directory; success means
40 /// portable mode (the disc/USB is writable), failure means we fall
41 /// back to the per-user data directory. Either way the `logs/`
42 /// subdirectory is created if missing.
43 ///
44 /// # Errors
45 /// Returns an error when [`std::env::current_exe`] fails or when the
46 /// platform user-data directory can't be resolved (only possible on
47 /// extremely unusual configurations).
48 pub fn resolve() -> Result<Self> {
49 let exe = std::env::current_exe().context("locating current exe")?;
50 let exe_dir = exe
51 .parent()
52 .context("exe has no parent directory")?
53 .to_path_buf();
54
55 let (data_dir, portable) = if dir_is_writable(&exe_dir) {
56 (exe_dir.clone(), true)
57 } else {
58 let proj = directories::ProjectDirs::from("dev", "AIMedic", "dicom-viewer")
59 .context("could not resolve user data directory")?;
60 (proj.data_dir().to_path_buf(), false)
61 };
62
63 let logs_dir = data_dir.join("logs");
64 fs::create_dir_all(&logs_dir).ok();
65
66 Ok(Self {
67 exe_dir,
68 data_dir,
69 logs_dir,
70 portable,
71 })
72 }
73
74 /// `true` if the data directory lives next to the executable
75 /// (writable exe dir), `false` if the user-data fallback is in use.
76 pub fn is_portable(&self) -> bool {
77 self.portable
78 }
79
80 /// Full path to `config.toml` (relative to [`Self::data_dir`]).
81 pub fn config_path(&self) -> PathBuf {
82 self.data_dir.join(CONFIG_FILE)
83 }
84}
85
86fn dir_is_writable(dir: &Path) -> bool {
87 let probe = dir.join(".dicom-viewer-write-probe");
88 match fs::write(&probe, b"") {
89 Ok(()) => {
90 let _ = fs::remove_file(&probe);
91 true
92 }
93 Err(_) => false,
94 }
95}
96
97/// On-disk user settings. Serialised to `config.toml` next to the
98/// executable (portable) or in the per-user data directory.
99///
100/// All fields default cleanly, so a missing or partial `config.toml` is
101/// not an error — see the `#[serde(default)]` on each subsection.
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103#[serde(default)]
104pub struct Config {
105 /// Remembered initial window size.
106 pub window: WindowConfig,
107 /// Theme + visible-panel state.
108 pub ui: UiConfig,
109 /// Whether the user has acknowledged the "Not for diagnostic use"
110 /// disclaimer. Once `true`, the disclaimer never reappears on this
111 /// machine.
112 pub disclaimer_acknowledged: bool,
113}
114
115/// Initial viewer window dimensions in logical pixels.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(default)]
118pub struct WindowConfig {
119 /// Width in logical pixels.
120 pub width: f32,
121 /// Height in logical pixels.
122 pub height: f32,
123}
124
125/// Persisted UI preferences (theme + panel visibility).
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(default)]
128pub struct UiConfig {
129 /// Currently the theme is dark-only; this field is reserved.
130 pub dark_mode: bool,
131 /// Show the right-hand metadata panel on startup.
132 pub show_metadata_panel: bool,
133 /// Show the left-hand study browser on startup.
134 pub show_study_browser: bool,
135}
136
137impl Default for WindowConfig {
138 fn default() -> Self {
139 Self {
140 width: 1280.0,
141 height: 800.0,
142 }
143 }
144}
145
146impl Default for UiConfig {
147 fn default() -> Self {
148 Self {
149 dark_mode: true,
150 show_metadata_panel: false,
151 show_study_browser: true,
152 }
153 }
154}
155
156impl Config {
157 /// Read `config.toml` from `paths.config_path()`. Returns
158 /// [`Config::default`] when the file is absent (first run).
159 ///
160 /// # Errors
161 /// Returns an error only when the file exists but cannot be read or
162 /// parsed as TOML.
163 pub fn load(paths: &Paths) -> Result<Self> {
164 let path = paths.config_path();
165 if !path.exists() {
166 return Ok(Self::default());
167 }
168 // nosemgrep: path-traversal — `path` is composed from `paths.data_dir`,
169 // which is derived from `std::env::current_exe()` or the platform
170 // user-data dir (see `Paths::resolve`). There is no untrusted input
171 // along this code path; this is a desktop binary, not an HTTP handler.
172 let text =
173 fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
174 let cfg: Self =
175 toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
176 Ok(cfg)
177 }
178
179 /// Write `self` as pretty TOML to `paths.config_path()`, creating the
180 /// parent directory if needed. Called on app exit.
181 ///
182 /// # Errors
183 /// Filesystem or serialisation failures.
184 pub fn save(&self, paths: &Paths) -> Result<()> {
185 let path = paths.config_path();
186 let text = toml::to_string_pretty(self).context("serializing config")?;
187 if let Some(parent) = path.parent() {
188 fs::create_dir_all(parent).ok();
189 }
190 fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
191 Ok(())
192 }
193}