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
136/// Why a file can't be read, from [`FileView::error`], as Quickshell's
137/// `FileViewError`.
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct FileError {
140  /// What kind of failure it is.
141  pub kind: FileErrorKind,
142  /// What the system said, such as `No such file or directory (os error
143  /// 2)`, or the shell, such as `it isn't UTF-8`.
144  pub message: String,
145}
146
147/// The kinds of [`FileError`].
148#[derive(Clone, Copy, Debug, PartialEq, Eq)]
149pub enum FileErrorKind {
150  /// Nothing is at the path.
151  NotFound,
152  /// The shell may not read it.
153  PermissionDenied,
154  /// Something is there, but it isn't a regular file, such as a directory
155  /// or a pipe.
156  NotAFile,
157  /// Anything else, such as a file that isn't UTF-8 or is over 16 MiB.
158  Other,
159}
160
161impl FileError {
162  fn new(kind: FileErrorKind, message: &str) -> FileError {
163    let message = message.to_owned();
164    FileError { kind, message }
165  }
166}
167
168impl From<std::io::Error> for FileError {
169  fn from(error: std::io::Error) -> FileError {
170    let kind = match error.kind() {
171      ErrorKind::NotFound => FileErrorKind::NotFound,
172      ErrorKind::PermissionDenied => FileErrorKind::PermissionDenied,
173      ErrorKind::IsADirectory => FileErrorKind::NotAFile,
174      _ => FileErrorKind::Other,
175    };
176    FileError::new(kind, &error.to_string())
177  }
178}
179
180struct ViewInner {
181  path: PathBuf,
182  follow: RefCell<Option<Rc<Follow>>>,
183  /// These live under the opening context's owner.
184  text: Signal<Option<String>>,
185  error: Signal<Option<FileError>>,
186}
187
188/// `cx.file_view(path)`.
189pub(crate) fn file_view(cx: &Cx, path: PathBuf) -> FileView {
190  let inner = Rc::new(ViewInner {
191    text: cx.signal(None),
192    error: cx.signal(None),
193    follow: RefCell::new(None),
194    path,
195  });
196  inner.reread();
197  if let Some(files) = cx.service::<Files>() {
198    let weak = Rc::downgrade(&inner);
199    let follow = Follow::new(&files.0, inner.path.clone(), move || {
200      if let Some(inner) = Weak::upgrade(&weak) {
201        inner.reread();
202      }
203    });
204    inner.follow.replace(Some(follow));
205  }
206  // So `text()` follows the file even once every `FileView` is dropped.
207  cx.owner().signal(Keep(Rc::clone(&inner)));
208  FileView { inner }
209}
210
211impl FileView {
212  /// The file's text, or `None` while it is missing, can't be read, isn't
213  /// UTF-8, or is over 16 MiB, each logged once until it reads again.
214  /// [`error`](Self::error) says why.
215  pub fn text(&self) -> ReadSignal<Option<String>> {
216    self.inner.text.read_only()
217  }
218
219  /// Why the last read failed, or `None` once a read or a
220  /// [`write`](Self::write) worked. A pipe, a directory or a device is
221  /// [`NotAFile`](FileErrorKind::NotAFile) and never opened.
222  pub fn error(&self) -> ReadSignal<Option<FileError>> {
223    self.inner.error.read_only()
224  }
225
226  /// Replaces the file's text, through a new file beside it renamed over
227  /// it, so a reader never sees half of it, making its directory if it is
228  /// missing. A link is followed and the file it names is replaced,
229  /// keeping its permissions. A failure is logged and the text stays as
230  /// it was.
231  pub fn write(&self, text: impl AsRef<str>) {
232    let text = text.as_ref();
233    match write_whole(&self.inner.path, text.as_bytes()) {
234      Ok(()) => {
235        set(self.inner.text, Some(text.to_owned()));
236        set(self.inner.error, None);
237      }
238      Err(error) => {
239        let path = self.inner.path.display();
240        tracing::warn!(%path, %error, "couldn't write the file");
241      }
242    }
243  }
244}
245
246impl ViewInner {
247  /// Reads the file again and shows what it holds now.
248  fn reread(&self) {
249    let (text, error) = match read_text(&self.path) {
250      Ok(text) => (Some(text), None),
251      Err(error) => {
252        // Once, while the same error stays.
253        if untrack(|| self.error.try_with(|old| old.as_ref() != Some(&error))) == Some(true) {
254          let path = self.path.display();
255          tracing::info!(%path, error = error.message, "can't read the file");
256        }
257        (None, Some(error))
258      }
259    };
260    set(self.text, text);
261    set(self.error, error);
262  }
263}
264
265/// A JSON file kept in step with a signal: `cx.json_file(path, default)`.
266pub(crate) fn json_file<T>(cx: &Cx, path: PathBuf, default: T) -> Signal<T>
267where
268  T: Serialize + DeserializeOwned + PartialEq + 'static,
269{
270  let value = cx.signal(default);
271  let file = Rc::new(JsonInner {
272    path,
273    value,
274    // What the file holds, as far as this shell knows.
275    on_disk: RefCell::new(None),
276    logged: RefCell::new(None),
277    follow: RefCell::new(None),
278  });
279  file.reread();
280  if let Some(files) = cx.service::<Files>() {
281    let weak = Rc::downgrade(&file);
282    let follow = Follow::new(&files.0, file.path.clone(), move || {
283      if let Some(file) = Weak::upgrade(&weak) {
284        file.reread();
285      }
286    });
287    file.follow.replace(Some(follow));
288  }
289  // The value it starts from, so the first run doesn't make a missing
290  // file the config never set.
291  let start = untrack(|| value.try_with(|value| file.to_json(value))).flatten();
292  let writer = Rc::clone(&file);
293  let first = Cell::new(true);
294  cx.effect(move || {
295    let text = value.with(|value| writer.to_json(value));
296    let unset = first.replace(false) && start.is_some() && text == start;
297    if let (Some(text), false) = (text, unset) {
298      untrack(|| writer.write(text));
299    }
300  })
301  .named(format!("cx.json_file {}", file.path.display()));
302  value
303}
304
305struct JsonInner<T: 'static> {
306  path: PathBuf,
307  value: Signal<T>,
308  on_disk: RefCell<Option<String>>,
309  /// The last read problem logged, so it logs once until it reads again.
310  logged: RefCell<Option<String>>,
311  follow: RefCell<Option<Rc<Follow>>>,
312}
313
314impl<T> JsonInner<T>
315where
316  T: Serialize + DeserializeOwned + PartialEq + 'static,
317{
318  /// Reads the file again. A missing file keeps the value; one that
319  /// isn't the type's JSON is logged and keeps it too. The config's
320  /// `Deserialize`, `Serialize` and `PartialEq` may panic, and this may
321  /// run from the loop, where nothing else would catch them.
322  fn reread(&self) {
323    let text = match read_text(&self.path) {
324      Ok(text) => text,
325      Err(error) => {
326        self.log_once(format!("can't read the file: {}", error.message));
327        return;
328      }
329    };
330    let applied = panic::catch_unwind(AssertUnwindSafe(|| {
331      let read = serde_json::from_str::<T>(&text)?;
332      let normal = serde_json::to_string_pretty(&read).unwrap_or_else(|_| text.clone());
333      self.on_disk.replace(Some(normal));
334      set(self.value, read);
335      serde_json::Result::Ok(())
336    }));
337    match applied {
338      Ok(Ok(())) => {
339        self.logged.replace(None);
340      }
341      Ok(Err(error)) => self.log_once(format!("the file isn't the JSON expected: {error}")),
342      Err(payload) => {
343        let message = panic_message(payload.as_ref());
344        self.log_once(format!("reading it panicked: {message}"));
345      }
346    }
347  }
348
349  /// `value` as JSON, or `None` when the config's `Serialize` fails or
350  /// panics, which is logged.
351  fn to_json(&self, value: &T) -> Option<String> {
352    let path = self.path.display();
353    match panic::catch_unwind(AssertUnwindSafe(|| serde_json::to_string_pretty(value))) {
354      Ok(Ok(text)) => Some(text),
355      Ok(Err(error)) => {
356        tracing::warn!(%path, %error, "couldn't turn the value into JSON");
357        None
358      }
359      Err(payload) => {
360        let message = panic_message(payload.as_ref());
361        tracing::error!("cx.json_file {path}: turning the value into JSON panicked: {message}");
362        None
363      }
364    }
365  }
366
367  fn write(&self, text: String) {
368    if self.on_disk.borrow().as_deref() == Some(text.as_str()) {
369      return;
370    }
371    match write_whole(&self.path, format!("{text}\n").as_bytes()) {
372      Ok(()) => {
373        self.on_disk.replace(Some(text));
374      }
375      Err(error) => {
376        let path = self.path.display();
377        tracing::warn!(%path, %error, "couldn't write the file");
378      }
379    }
380  }
381
382  fn log_once(&self, problem: String) {
383    if self.logged.borrow().as_deref() != Some(problem.as_str()) {
384      let path = self.path.display();
385      tracing::info!(%path, problem, "the JSON file keeps its value");
386      self.logged.replace(Some(problem));
387    }
388  }
389}
390
391/// The whole of `path` as UTF-8, up to [`MAX_FILE`].
392fn read_text(path: &Path) -> Result<String, FileError> {
393  // Checked before the open, so a device or a pipe is never opened.
394  if !std::fs::metadata(path)?.is_file() {
395    return Err(FileError::new(FileErrorKind::NotAFile, "it isn't a file"));
396  }
397  let file = open_file(path)?;
398  let mut bytes = Vec::new();
399  file.take(MAX_FILE + 1).read_to_end(&mut bytes)?;
400  if bytes.len() as u64 > MAX_FILE {
401    return Err(FileError::new(FileErrorKind::Other, "it is over 16 MiB"));
402  }
403  String::from_utf8(bytes).map_err(|_| FileError::new(FileErrorKind::Other, "it isn't UTF-8"))
404}
405
406/// `path` opened to read, if it is a regular file. The open doesn't wait,
407/// as it would for a writer on a pipe put there since a check.
408fn open_file(path: &Path) -> Result<std::fs::File, FileError> {
409  use rustix::fs::{Mode, OFlags};
410
411  let flags = OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOCTTY | OFlags::CLOEXEC;
412  let fd = rustix::fs::open(path, flags, Mode::empty()).map_err(std::io::Error::from)?;
413  let file = std::fs::File::from(fd);
414  if !file.metadata()?.is_file() {
415    return Err(FileError::new(FileErrorKind::NotAFile, "it isn't a file"));
416  }
417  // A regular file's reads never wait anyway; this says so.
418  rustix::fs::fcntl_setfl(&file, OFlags::empty()).map_err(std::io::Error::from)?;
419  Ok(file)
420}
421
422/// Replaces the file at `path` with `bytes` through a new file beside it,
423/// renamed over it, making its directory if it is missing. A link is
424/// followed, so the file it names is replaced, and a file's permissions
425/// are kept.
426fn write_whole(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
427  use std::os::unix::fs::OpenOptionsExt;
428
429  if let Some(dir) = path.parent()
430    && !dir.as_os_str().is_empty()
431    && !dir.exists()
432  {
433    std::fs::create_dir_all(dir)?;
434  }
435  let target = match std::fs::canonicalize(path) {
436    Ok(target) => target,
437    Err(error) if error.kind() == ErrorKind::NotFound => path.to_owned(),
438    Err(error) => return Err(error),
439  };
440  let name = target
441    .file_name()
442    .ok_or_else(|| std::io::Error::other("the path names no file"))?;
443  let mut partial_name = std::ffi::OsString::from(".");
444  partial_name.push(name);
445  partial_name.push(".crabshell-partial");
446  let partial = target.with_file_name(partial_name);
447  match std::fs::remove_file(&partial) {
448    Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
449    _ => {}
450  }
451  let mode = std::fs::metadata(&target).map_or(0o644, |meta| {
452    use std::os::unix::fs::PermissionsExt;
453    meta.permissions().mode() & 0o7777
454  });
455  // New, so it never writes through a link left in its place.
456  let mut file = std::fs::OpenOptions::new()
457    .write(true)
458    .create_new(true)
459    .mode(mode)
460    .open(&partial)?;
461  let written = file.write_all(bytes).and_then(|()| file.sync_all());
462  drop(file);
463  if let Err(error) = written.and_then(|()| std::fs::rename(&partial, &target)) {
464    let _ = std::fs::remove_file(&partial);
465    return Err(error);
466  }
467  Ok(())
468}
469
470/// Sets `signal` unless it holds `value` or is gone. The read is
471/// untracked, so an effect that opens a file doesn't run again for it.
472fn set<T: PartialEq + 'static>(signal: Signal<T>, value: T) {
473  if untrack(|| signal.try_with(|old| *old != value)) == Some(true) {
474    signal.try_set(value);
475  }
476}