Skip to main content

crabshell_services/
file.rs

1//! Files a config reads and writes, as Quickshell's `FileView` and
2//! `JsonAdapter`.
3
4#[cfg(test)]
5mod tests;
6
7use std::cell::{Cell, RefCell};
8use std::ffi::OsString;
9use std::io::{ErrorKind, Read, Write};
10use std::panic::{self, AssertUnwindSafe};
11use std::path::{Path, PathBuf};
12use std::rc::{Rc, Weak};
13
14use crabshell_reactive::{ReadSignal, Signal, untrack};
15use crabshell_ui::Cx;
16use inotify::EventMask;
17use serde::Serialize;
18use serde::de::DeserializeOwned;
19
20use crate::Shared;
21use crate::lines::panic_message;
22use crate::watch::{self, Watch};
23
24/// The largest file read; a larger one reads as missing, and that is
25/// logged.
26pub(crate) const MAX_FILE: u64 = 16 * 1024 * 1024;
27
28/// What `cx.file_view` and `cx.json_file` need from the shell: the loop,
29/// to watch files.
30pub(crate) struct Files(pub(crate) Rc<Shared>);
31
32/// Follows the file at a path through whatever happens to the directories
33/// on the way: one not made yet is waited for from the nearest one that
34/// is there, and one removed, renamed or made again is followed anew.
35// shortcut: watches the directory the path names, not a link's target's,
36// so an edit through another name of a linked file isn't seen; follow
37// the link when a config needs that.
38struct Follow {
39  shared: Rc<Shared>,
40  path: PathBuf,
41  watch: RefCell<Option<Watch>>,
42  /// Reads the file again.
43  changed: Box<dyn Fn()>,
44}
45
46impl Follow {
47  /// Calls `changed` when the file at `path` may have changed: written,
48  /// replaced or removed.
49  fn new(shared: &Rc<Shared>, path: PathBuf, changed: impl Fn() + 'static) -> Rc<Follow> {
50    let follow = Rc::new(Follow {
51      shared: Rc::clone(shared),
52      path,
53      watch: RefCell::new(None),
54      changed: Box::new(changed),
55    });
56    follow.rewatch();
57    follow
58  }
59
60  /// Watches the file's directory, or while it is missing the nearest
61  /// directory above it that is there, for the next name on the way.
62  fn rewatch(self: &Rc<Self>) {
63    self.watch.replace(None);
64    let (Some(dir), Some(name)) = (self.path.parent(), self.path.file_name()) else {
65      return;
66    };
67    let mut dir = if dir.as_os_str().is_empty() {
68      Path::new(".")
69    } else {
70      dir
71    };
72    let mut next: OsString = name.to_owned();
73    let own = dir.is_dir();
74    while !dir.is_dir() {
75      let (Some(above), Some(name)) = (dir.parent(), dir.file_name()) else {
76        return;
77      };
78      next = name.to_owned();
79      dir = if above.as_os_str().is_empty() {
80        Path::new(".")
81      } else {
82        above
83      };
84    }
85    let weak = Rc::downgrade(self);
86    let listener = move |change: &watch::Change| {
87      let Some(follow) = weak.upgrade() else {
88        return;
89      };
90      let moved = EventMask::DELETE_SELF | EventMask::MOVE_SELF;
91      match &change.name {
92        // The directory itself went, or changes were lost: start over.
93        None => {
94          if change.mask.intersects(moved) || change.mask.contains(EventMask::Q_OVERFLOW) {
95            follow.rewatch();
96          }
97          (follow.changed)();
98        }
99        // Not on a create, when a writer hasn't written yet: its close
100        // comes next.
101        Some(changed) if *changed == next && own => {
102          if !change.mask.contains(EventMask::CREATE) {
103            (follow.changed)();
104          }
105        }
106        // A directory on the way came or went.
107        Some(changed) if *changed == next => {
108          follow.rewatch();
109          (follow.changed)();
110        }
111        Some(_) => {}
112      }
113    };
114    match watch::watch(&self.shared, dir, listener) {
115      Ok(watch) => {
116        self.watch.replace(Some(watch));
117      }
118      Err(error) => {
119        let path = self.path.display();
120        tracing::info!(%path, %error, "can't watch the file, so changes to it aren't seen");
121      }
122    }
123  }
124}
125
126/// Keeps a file's watch for as long as the opening context's owner.
127struct Keep<T>(#[allow(dead_code, reason = "held for its drop")] Rc<T>);
128
129/// A text file, from `cx.file_view(path)`. Its text is read at once and
130/// again when it changes on disk. A clone is cheap and shares it.
131#[derive(Clone)]
132pub struct FileView {
133  inner: Rc<ViewInner>,
134}
135
136struct ViewInner {
137  path: PathBuf,
138  follow: RefCell<Option<Rc<Follow>>>,
139  /// Lives under the opening context's owner.
140  text: Signal<Option<String>>,
141  /// The last error logged, so a file that stays missing logs once.
142  logged: RefCell<Option<ErrorKind>>,
143}
144
145/// `cx.file_view(path)`.
146pub(crate) fn file_view(cx: &Cx, path: PathBuf) -> FileView {
147  let inner = Rc::new(ViewInner {
148    text: cx.signal(None),
149    follow: RefCell::new(None),
150    path,
151    logged: RefCell::new(None),
152  });
153  inner.reread();
154  if let Some(files) = cx.service::<Files>() {
155    let weak = Rc::downgrade(&inner);
156    let follow = Follow::new(&files.0, inner.path.clone(), move || {
157      if let Some(inner) = Weak::upgrade(&weak) {
158        inner.reread();
159      }
160    });
161    inner.follow.replace(Some(follow));
162  }
163  // So `text()` follows the file even once every `FileView` is dropped.
164  cx.owner().signal(Keep(Rc::clone(&inner)));
165  FileView { inner }
166}
167
168impl FileView {
169  /// The file's text, or `None` while it is missing, can't be read, isn't
170  /// UTF-8, or is over 16 MiB, each logged once until it reads again.
171  pub fn text(&self) -> ReadSignal<Option<String>> {
172    self.inner.text.read_only()
173  }
174
175  /// Replaces the file's text, through a new file beside it renamed over
176  /// it, so a reader never sees half of it, making its directory if it is
177  /// missing. A link is followed and the file it names is replaced,
178  /// keeping its permissions. A failure is logged and the text stays as
179  /// it was.
180  pub fn write(&self, text: impl AsRef<str>) {
181    let text = text.as_ref();
182    match write_whole(&self.inner.path, text.as_bytes()) {
183      Ok(()) => set(self.inner.text, Some(text.to_owned())),
184      Err(error) => {
185        let path = self.inner.path.display();
186        tracing::warn!(%path, %error, "couldn't write the file");
187      }
188    }
189  }
190}
191
192impl ViewInner {
193  /// Reads the file again and shows what it holds now.
194  fn reread(&self) {
195    let text = match read_text(&self.path) {
196      Ok(text) => {
197        self.logged.replace(None);
198        Some(text)
199      }
200      Err(error) => {
201        if self.logged.replace(Some(error.kind())) != Some(error.kind()) {
202          let path = self.path.display();
203          tracing::info!(%path, %error, "can't read the file");
204        }
205        None
206      }
207    };
208    set(self.text, text);
209  }
210}
211
212/// A JSON file kept in step with a signal: `cx.json_file(path, default)`.
213pub(crate) fn json_file<T>(cx: &Cx, path: PathBuf, default: T) -> Signal<T>
214where
215  T: Serialize + DeserializeOwned + PartialEq + 'static,
216{
217  let value = cx.signal(default);
218  let file = Rc::new(JsonInner {
219    path,
220    value,
221    // What the file holds, as far as this shell knows.
222    on_disk: RefCell::new(None),
223    logged: RefCell::new(None),
224    follow: RefCell::new(None),
225  });
226  file.reread();
227  if let Some(files) = cx.service::<Files>() {
228    let weak = Rc::downgrade(&file);
229    let follow = Follow::new(&files.0, file.path.clone(), move || {
230      if let Some(file) = Weak::upgrade(&weak) {
231        file.reread();
232      }
233    });
234    file.follow.replace(Some(follow));
235  }
236  // The value it starts from, so the first run doesn't make a missing
237  // file the config never set.
238  let start = untrack(|| value.try_with(|value| file.to_json(value))).flatten();
239  let writer = Rc::clone(&file);
240  let first = Cell::new(true);
241  cx.effect(move || {
242    let text = value.with(|value| writer.to_json(value));
243    let unset = first.replace(false) && start.is_some() && text == start;
244    if let (Some(text), false) = (text, unset) {
245      untrack(|| writer.write(text));
246    }
247  })
248  .named(format!("cx.json_file {}", file.path.display()));
249  value
250}
251
252struct JsonInner<T: 'static> {
253  path: PathBuf,
254  value: Signal<T>,
255  on_disk: RefCell<Option<String>>,
256  /// The last read problem logged, so it logs once until it reads again.
257  logged: RefCell<Option<String>>,
258  follow: RefCell<Option<Rc<Follow>>>,
259}
260
261impl<T> JsonInner<T>
262where
263  T: Serialize + DeserializeOwned + PartialEq + 'static,
264{
265  /// Reads the file again. A missing file keeps the value; one that
266  /// isn't the type's JSON is logged and keeps it too. The config's
267  /// `Deserialize`, `Serialize` and `PartialEq` may panic, and this may
268  /// run from the loop, where nothing else would catch them.
269  fn reread(&self) {
270    let text = match read_text(&self.path) {
271      Ok(text) => text,
272      Err(error) => {
273        self.log_once(format!("can't read the file: {error}"));
274        return;
275      }
276    };
277    let applied = panic::catch_unwind(AssertUnwindSafe(|| {
278      let read = serde_json::from_str::<T>(&text)?;
279      let normal = serde_json::to_string_pretty(&read).unwrap_or_else(|_| text.clone());
280      self.on_disk.replace(Some(normal));
281      set(self.value, read);
282      serde_json::Result::Ok(())
283    }));
284    match applied {
285      Ok(Ok(())) => {
286        self.logged.replace(None);
287      }
288      Ok(Err(error)) => self.log_once(format!("the file isn't the JSON expected: {error}")),
289      Err(payload) => {
290        let message = panic_message(payload.as_ref());
291        self.log_once(format!("reading it panicked: {message}"));
292      }
293    }
294  }
295
296  /// `value` as JSON, or `None` when the config's `Serialize` fails or
297  /// panics, which is logged.
298  fn to_json(&self, value: &T) -> Option<String> {
299    let path = self.path.display();
300    match panic::catch_unwind(AssertUnwindSafe(|| serde_json::to_string_pretty(value))) {
301      Ok(Ok(text)) => Some(text),
302      Ok(Err(error)) => {
303        tracing::warn!(%path, %error, "couldn't turn the value into JSON");
304        None
305      }
306      Err(payload) => {
307        let message = panic_message(payload.as_ref());
308        tracing::error!("cx.json_file {path}: turning the value into JSON panicked: {message}");
309        None
310      }
311    }
312  }
313
314  fn write(&self, text: String) {
315    if self.on_disk.borrow().as_deref() == Some(text.as_str()) {
316      return;
317    }
318    match write_whole(&self.path, format!("{text}\n").as_bytes()) {
319      Ok(()) => {
320        self.on_disk.replace(Some(text));
321      }
322      Err(error) => {
323        let path = self.path.display();
324        tracing::warn!(%path, %error, "couldn't write the file");
325      }
326    }
327  }
328
329  fn log_once(&self, problem: String) {
330    if self.logged.borrow().as_deref() != Some(problem.as_str()) {
331      let path = self.path.display();
332      tracing::info!(%path, problem, "the JSON file keeps its value");
333      self.logged.replace(Some(problem));
334    }
335  }
336}
337
338/// The whole of `path` as UTF-8, up to [`MAX_FILE`].
339fn read_text(path: &Path) -> std::io::Result<String> {
340  let file = std::fs::File::open(path)?;
341  let mut bytes = Vec::new();
342  file.take(MAX_FILE + 1).read_to_end(&mut bytes)?;
343  if bytes.len() as u64 > MAX_FILE {
344    return Err(std::io::Error::other("it is over 16 MiB"));
345  }
346  String::from_utf8(bytes)
347    .map_err(|_| std::io::Error::new(ErrorKind::InvalidData, "it isn't UTF-8"))
348}
349
350/// Replaces the file at `path` with `bytes` through a new file beside it,
351/// renamed over it, making its directory if it is missing. A link is
352/// followed, so the file it names is replaced, and a file's permissions
353/// are kept.
354fn write_whole(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
355  use std::os::unix::fs::OpenOptionsExt;
356
357  if let Some(dir) = path.parent()
358    && !dir.as_os_str().is_empty()
359    && !dir.exists()
360  {
361    std::fs::create_dir_all(dir)?;
362  }
363  let target = match std::fs::canonicalize(path) {
364    Ok(target) => target,
365    Err(error) if error.kind() == ErrorKind::NotFound => path.to_owned(),
366    Err(error) => return Err(error),
367  };
368  let name = target
369    .file_name()
370    .ok_or_else(|| std::io::Error::other("the path names no file"))?;
371  let mut partial_name = std::ffi::OsString::from(".");
372  partial_name.push(name);
373  partial_name.push(".crabshell-partial");
374  let partial = target.with_file_name(partial_name);
375  match std::fs::remove_file(&partial) {
376    Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
377    _ => {}
378  }
379  let mode = std::fs::metadata(&target).map_or(0o644, |meta| {
380    use std::os::unix::fs::PermissionsExt;
381    meta.permissions().mode() & 0o7777
382  });
383  // New, so it never writes through a link left in its place.
384  let mut file = std::fs::OpenOptions::new()
385    .write(true)
386    .create_new(true)
387    .mode(mode)
388    .open(&partial)?;
389  let written = file.write_all(bytes).and_then(|()| file.sync_all());
390  drop(file);
391  if let Err(error) = written.and_then(|()| std::fs::rename(&partial, &target)) {
392    let _ = std::fs::remove_file(&partial);
393    return Err(error);
394  }
395  Ok(())
396}
397
398/// Sets `signal` unless it holds `value` or is gone. The read is
399/// untracked, so an effect that opens a file doesn't run again for it.
400fn set<T: PartialEq + 'static>(signal: Signal<T>, value: T) {
401  if untrack(|| signal.try_with(|old| *old != value)) == Some(true) {
402    signal.try_set(value);
403  }
404}