git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

src/session.rs (5K)

  1 //! Open-tab persistence: the tab set survives a restart.
  2 //!
  3 //! `~/.local/state/cce/browser/tabs.tsv` (the same state dir as history and
  4 //! bookmarks), one line per tab in strip order: `<1|0>\t<url>`, the flag
  5 //! marking the active tab. It is written eagerly on every tab-set change —
  6 //! open, close, switch, navigation — rather than on exit, so a crash or a
  7 //! compositor-side window close loses nothing; `save` skips the write when
  8 //! the serialization is unchanged, which keeps the loading-time signal storm
  9 //! from touching the disk more than once. Closing the last tab saves an
 10 //! empty set, so a deliberately emptied browser starts fresh on the
 11 //! homepage rather than resurrecting what was just closed.
 12 
 13 use std::path::PathBuf;
 14 
 15 use url::Url;
 16 
 17 pub struct Session {
 18     path: PathBuf,
 19     /// Last serialization written (or loaded), to skip no-op writes.
 20     last: Option<String>,
 21 }
 22 
 23 impl Session {
 24     pub fn new() -> Self {
 25         Self::at(crate::pages::state_dir().join("tabs.tsv"))
 26     }
 27 
 28     fn at(path: PathBuf) -> Self {
 29         Self { path, last: None }
 30     }
 31 
 32     /// Tabs saved by the previous run, in strip order, plus the active
 33     /// index. Missing file or unparseable lines mean fewer tabs, never an
 34     /// error; an empty result is "nothing to restore".
 35     pub fn load(&mut self) -> (Vec<Url>, usize) {
 36         let text = std::fs::read_to_string(&self.path).unwrap_or_default();
 37         let mut tabs = Vec::new();
 38         let mut active = 0;
 39         for line in text.lines() {
 40             let mut parts = line.splitn(2, '\t');
 41             if let (Some(flag), Some(url)) = (parts.next(), parts.next()) {
 42                 if let Ok(u) = Url::parse(url) {
 43                     if flag == "1" {
 44                         active = tabs.len();
 45                     }
 46                     tabs.push(u);
 47                 }
 48             }
 49         }
 50         self.last = Some(text);
 51         (tabs, active)
 52     }
 53 
 54     /// Persist the open tabs; a no-op when nothing changed since the last
 55     /// write.
 56     pub fn save(&mut self, tabs: &[(String, bool)]) {
 57         let mut out = String::new();
 58         for (url, active) in tabs {
 59             out.push_str(if *active { "1\t" } else { "0\t" });
 60             out.push_str(url);
 61             out.push('\n');
 62         }
 63         if self.last.as_deref() == Some(&out) {
 64             return;
 65         }
 66         if let Some(dir) = self.path.parent() {
 67             let _ = std::fs::create_dir_all(dir);
 68         }
 69         if std::fs::write(&self.path, &out).is_ok() {
 70             self.last = Some(out);
 71         }
 72     }
 73 }
 74 
 75 #[cfg(test)]
 76 mod tests {
 77     use super::*;
 78 
 79     /// A session file in a scratch directory of this PROCESS's own.
 80     ///
 81     /// The name was fixed — `/tmp/cce-browser-session-test` — where the
 82     /// sibling helpers in `pages.rs` already scope theirs by pid. /tmp is one
 83     /// namespace shared by every user of the machine, so a fixed name belongs
 84     /// to whoever ran first and the sticky bit denies it to everyone else;
 85     /// nearer to hand, two checkouts running their suites at once shared one
 86     /// directory.
 87     ///
 88     /// The directory is deliberately NOT removed wholesale, here or at the
 89     /// end of a test: the three tests run in parallel threads of one process
 90     /// and so share this one pid-scoped directory, and a `remove_dir_all`
 91     /// would take a sibling's file out from under it. Each test owns a
 92     /// distinct file name and clears just that.
 93     fn temp_session(name: &str) -> Session {
 94         let dir = std::env::temp_dir()
 95             .join(format!("cce-browser-session-test-{}", std::process::id()));
 96         std::fs::create_dir_all(&dir).unwrap();
 97         let path = dir.join(name);
 98         let _ = std::fs::remove_file(&path);
 99         Session::at(path)
100     }
101 
102     #[test]
103     fn round_trips_tabs_and_active_index() {
104         let mut s = temp_session("round-trip.tsv");
105         s.save(&[
106             ("https://example.com/".to_string(), false),
107             ("https://example.org/".to_string(), true),
108         ]);
109 
110         let mut fresh = Session::at(s.path.clone());
111         let (tabs, active) = fresh.load();
112         assert_eq!(
113             tabs.iter().map(Url::as_str).collect::<Vec<_>>(),
114             ["https://example.com/", "https://example.org/"]
115         );
116         assert_eq!(active, 1);
117     }
118 
119     #[test]
120     fn empty_save_clears_and_loads_as_nothing() {
121         let mut s = temp_session("empty.tsv");
122         s.save(&[("https://example.com/".to_string(), true)]);
123         s.save(&[]);
124 
125         let (tabs, active) = Session::at(s.path.clone()).load();
126         assert!(tabs.is_empty());
127         assert_eq!(active, 0);
128     }
129 
130     #[test]
131     fn missing_file_and_junk_lines_load_as_fewer_tabs() {
132         let mut s = temp_session("missing.tsv");
133         assert!(s.load().0.is_empty());
134 
135         std::fs::write(
136             &s.path,
137             "no-tab-here\n1\tnot a url\n0\thttps://example.com/\n",
138         )
139         .unwrap();
140         let (tabs, active) = Session::at(s.path.clone()).load();
141         assert_eq!(tabs.len(), 1);
142         assert_eq!(active, 0);
143     }
144 }