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

src/pages.rs (26K)

  1 //! Internal `cce:` pages and their backing stores.
  2 //!
  3 //! History, bookmarks and favorites live as TSV files under the XDG state
  4 //! dir (`~/.local/state/cce/browser/`), with in-memory copies for rendering.
  5 //! The `cce:` protocol handler serves them back as real pages —
  6 //! `cce://history` and `cce://bookmarks` are fetched through Servo's
  7 //! network stack and rendered like any other page, so entries are
  8 //! ordinary links (including the mutating clear/remove actions).
  9 //!
 10 //! The handler runs on Servo's fetch threads, hence the `Arc<Mutex<_>>`
 11 //! stores shared with the main thread.
 12 
 13 use std::fs::{self, OpenOptions};
 14 #[cfg(feature = "servo")]
 15 use std::future::Future;
 16 use std::io::Write;
 17 use std::path::PathBuf;
 18 #[cfg(feature = "servo")]
 19 use std::pin::Pin;
 20 use std::sync::{Arc, Mutex};
 21 use std::time::{SystemTime, UNIX_EPOCH};
 22 
 23 #[cfg(feature = "servo")]
 24 use servo::protocol_handler::{
 25     DoneChannel, FetchContext, HttpStatus, NetworkError, ProtocolHandler, Request, Response,
 26     ResponseBody, ResourceFetchTiming,
 27 };
 28 
 29 /// Render at most this many entries on the history page.
 30 const RENDER_CAP: usize = 500;
 31 
 32 #[derive(Clone)]
 33 struct Entry {
 34     ts: u64,
 35     url: String,
 36     title: String,
 37 }
 38 
 39 /// `~/.local/state/cce/browser` — history and bookmarks live here directly,
 40 /// Servo's own persisted state in a `profile` subdirectory under it.
 41 pub(crate) fn state_dir() -> PathBuf {
 42     let base = match std::env::var("XDG_STATE_HOME") {
 43         Ok(x) if !x.is_empty() => PathBuf::from(x),
 44         _ => PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state"),
 45     };
 46     base.join("cce").join("browser")
 47 }
 48 
 49 fn now() -> u64 {
 50     SystemTime::now()
 51         .duration_since(UNIX_EPOCH)
 52         .map(|d| d.as_secs())
 53         .unwrap_or(0)
 54 }
 55 
 56 /// One-line-safe field: the TSV logs separate with tabs and newlines.
 57 fn sanitize(s: &str) -> String {
 58     s.replace(['\t', '\n', '\r'], " ")
 59 }
 60 
 61 pub(crate) fn html_escape(s: &str) -> String {
 62     s.replace('&', "&amp;")
 63         .replace('<', "&lt;")
 64         .replace('>', "&gt;")
 65         .replace('"', "&quot;")
 66 }
 67 
 68 fn read_tsv(path: &PathBuf) -> Vec<Entry> {
 69     let mut entries = Vec::new();
 70     if let Ok(text) = fs::read_to_string(path) {
 71         for line in text.lines() {
 72             let mut parts = line.splitn(3, '\t');
 73             if let (Some(ts), Some(url), Some(title)) = (parts.next(), parts.next(), parts.next())
 74             {
 75                 if let Ok(ts) = ts.parse() {
 76                     entries.push(Entry { ts, url: url.to_string(), title: title.to_string() });
 77                 }
 78             }
 79         }
 80     }
 81     entries
 82 }
 83 
 84 fn write_tsv(path: &PathBuf, entries: &[Entry]) {
 85     if let Some(dir) = path.parent() {
 86         let _ = fs::create_dir_all(dir);
 87     }
 88     let mut out = String::new();
 89     for e in entries {
 90         out.push_str(&format!("{}\t{}\t{}\n", e.ts, e.url, e.title));
 91     }
 92     let _ = fs::write(path, out);
 93 }
 94 
 95 /// Shared page skeleton for the internal pages (dark, DE-toned).
 96 /// `head_extra` lands in <head> (e.g. a refresh tag for live pages).
 97 pub(crate) fn page(title: &str, meta: &str, body: &str, head_extra: &str) -> String {
 98     format!(
 99         "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>{title}</title>{head_extra}<style>\
100          :root{{color-scheme:dark}}\
101          body{{background:#1a1b1d;color:#dcdce1;font-family:sans-serif;margin:0;padding:28px 36px}}\
102          h1{{font-size:20px;font-weight:600;margin:0 0 4px}}\
103          .meta{{color:#8a8c92;font-size:13px;margin-bottom:20px}}\
104          .meta a{{color:#7fa3d4;text-decoration:none;margin-left:12px}}\
105          .e{{display:flex;gap:14px;padding:7px 10px;border-radius:8px;align-items:baseline}}\
106          .e:hover{{background:#232427}}\
107          .w{{color:#8a8c92;font-size:12px;min-width:11em}}\
108          .e a{{color:#dcdce1;text-decoration:none;white-space:nowrap;overflow:hidden;\
109                text-overflow:ellipsis;max-width:40%}}\
110          .e a:hover{{color:#9fc1ea}}\
111          .u{{color:#6f7177;font-size:12px;white-space:nowrap;overflow:hidden;\
112              text-overflow:ellipsis;flex:1}}\
113          .e a.rm{{color:#6f7177;font-size:12px;max-width:none}}\
114          .e a.rm:hover{{color:#d49b9b}}\
115          .e .tag{{color:#7fa3d4;font-size:12px}}\
116          .empty{{color:#8a8c92}}\
117          </style></head><body>\
118          <h1>{title}</h1>\
119          <div class=meta>{meta}</div>\
120          {body}\
121          <script>for(const el of document.querySelectorAll('[data-ts]')){{\
122          const d=new Date(1000*+el.dataset.ts);\
123          el.textContent=d.toLocaleDateString()+'  '+\
124          d.toLocaleTimeString([],{{hour:'2-digit',minute:'2-digit'}});}}</script>\
125          </body></html>"
126     )
127 }
128 
129 pub struct History {
130     entries: Mutex<Vec<Entry>>,
131     path: PathBuf,
132 }
133 
134 impl History {
135     /// Load the log from the state dir (missing file = empty history).
136     pub fn load() -> Self {
137         let path = state_dir().join("history.tsv");
138         Self { entries: Mutex::new(read_tsv(&path)), path }
139     }
140 
141     /// Record a completed page load. Internal pages and immediate
142     /// duplicates (reload spam) are skipped.
143     pub fn record(&self, url: &str, title: &str) {
144         if url.starts_with("cce:") || url == "about:blank" {
145             return;
146         }
147         let mut entries = self.entries.lock().unwrap();
148         if entries.last().is_some_and(|last| last.url == url) {
149             return;
150         }
151         let entry = Entry { ts: now(), url: sanitize(url), title: sanitize(title) };
152         if let Some(dir) = self.path.parent() {
153             let _ = fs::create_dir_all(dir);
154         }
155         if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&self.path) {
156             let _ = writeln!(f, "{}\t{}\t{}", entry.ts, entry.url, entry.title);
157         }
158         entries.push(entry);
159     }
160 
161     pub fn clear(&self) {
162         self.entries.lock().unwrap().clear();
163         let _ = fs::write(&self.path, "");
164     }
165 
166     fn html(&self) -> String {
167         let entries = self.entries.lock().unwrap();
168         let mut rows = String::new();
169         for e in entries.iter().rev().take(RENDER_CAP) {
170             let title = if e.title.trim().is_empty() { &e.url } else { &e.title };
171             rows.push_str(&format!(
172                 "<div class=e><span class=w data-ts=\"{}\"></span>\
173                  <a href=\"{}\">{}</a><span class=u>{}</span></div>\n",
174                 e.ts,
175                 html_escape(&e.url),
176                 html_escape(title),
177                 html_escape(&e.url),
178             ));
179         }
180         let meta = format!(
181             "{} entries<a href=\"cce://history/clear\">clear</a>",
182             entries.len()
183         );
184         let body = if entries.is_empty() {
185             "<p class=empty>No history yet.</p>".to_string()
186         } else {
187             rows
188         };
189         page("History", &meta, &body, "")
190     }
191 }
192 
193 pub struct Bookmarks {
194     entries: Mutex<Vec<Entry>>,
195     path: PathBuf,
196 }
197 
198 impl Bookmarks {
199     pub fn load() -> Self {
200         let path = state_dir().join("bookmarks.tsv");
201         Self { entries: Mutex::new(read_tsv(&path)), path }
202     }
203 
204     pub fn contains(&self, url: &str) -> bool {
205         self.entries.lock().unwrap().iter().any(|e| e.url == url)
206     }
207 
208     /// Add or remove a bookmark for `url`; returns true when it is now
209     /// bookmarked.
210     pub fn toggle(&self, url: &str, title: &str) -> bool {
211         if url.starts_with("cce:") || url == "about:blank" {
212             return false;
213         }
214         let mut entries = self.entries.lock().unwrap();
215         let added = if let Some(i) = entries.iter().position(|e| e.url == url) {
216             entries.remove(i);
217             false
218         } else {
219             entries.push(Entry { ts: now(), url: sanitize(url), title: sanitize(title) });
220             true
221         };
222         write_tsv(&self.path, &entries);
223         added
224     }
225 
226     pub fn remove(&self, url: &str) {
227         let mut entries = self.entries.lock().unwrap();
228         entries.retain(|e| e.url != url);
229         write_tsv(&self.path, &entries);
230     }
231 
232     /// The bookmarks as the chrome's menu lists them: newest first, the
233     /// same order the `cce://bookmarks` page renders.
234     pub fn snapshot(&self) -> Vec<Link> {
235         self.entries
236             .lock()
237             .unwrap()
238             .iter()
239             .rev()
240             .map(|e| Link { url: e.url.clone(), label: default_label(&e.url, &e.title) })
241             .collect()
242     }
243 
244     /// The title a bookmark was saved with, for promoting it to a favorite
245     /// from the bookmarks page without re-fetching anything.
246     pub fn title_of(&self, url: &str) -> Option<String> {
247         self.entries
248             .lock()
249             .unwrap()
250             .iter()
251             .find(|e| e.url == url)
252             .map(|e| e.title.clone())
253     }
254 
255     fn html(&self, favorites: &Favorites) -> String {
256         let entries = self.entries.lock().unwrap();
257         let mut rows = String::new();
258         for e in entries.iter().rev() {
259             let title = if e.title.trim().is_empty() { &e.url } else { &e.title };
260             let enc = url_encode(&e.url);
261             // A bookmark that is already a favorite says so instead of
262             // offering to add it twice.
263             let fav = if favorites.contains(&e.url) {
264                 "<span class=tag>favorite</span>".to_string()
265             } else {
266                 format!("<a class=rm href=\"cce://favorites/add?url={}\">favorite</a>", html_escape(&enc))
267             };
268             rows.push_str(&format!(
269                 "<div class=e><span class=w data-ts=\"{}\"></span>\
270                  <a href=\"{}\">{}</a><span class=u>{}</span>{fav}\
271                  <a class=rm href=\"cce://bookmarks/remove?url={}\">remove</a></div>\n",
272                 e.ts,
273                 html_escape(&e.url),
274                 html_escape(title),
275                 html_escape(&e.url),
276                 html_escape(&enc),
277             ));
278         }
279         let meta = format!(
280             "{} bookmarks<a href=\"cce://favorites\">favorites</a>",
281             entries.len()
282         );
283         let body = if entries.is_empty() {
284             "<p class=empty>No bookmarks yet. Star a page or press Ctrl+D.</p>".to_string()
285         } else {
286             rows
287         };
288         page("Bookmarks", &meta, &body, "")
289     }
290 }
291 
292 fn url_encode(s: &str) -> String {
293     url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
294 }
295 
296 /// One saved place as the chrome shows it: a label and where it goes.
297 /// Shared by the favorites strip's pills and the bookmarks menu's rows —
298 /// both want a display label, not a raw URL.
299 #[derive(Clone, Debug, PartialEq)]
300 pub struct Link {
301     pub url: String,
302     pub label: String,
303 }
304 
305 /// The label a favorite gets when it is added: the page title, or — for an
306 /// untitled page — the host with any `www.` shorn off (the file name, for
307 /// a `file:` URL, which has no host), so a pill never reads as a full URL.
308 fn default_label(url: &str, title: &str) -> String {
309     let title = title.trim();
310     if !title.is_empty() {
311         return title.to_string();
312     }
313     let Ok(u) = url::Url::parse(url) else { return url.to_string() };
314     let host = u
315         .host_str()
316         .map(|h| h.trim_start_matches("www.").to_string())
317         .filter(|h| !h.is_empty());
318     let file = u
319         .path_segments()
320         .and_then(|mut segs| segs.next_back().map(str::to_string))
321         .filter(|f| !f.is_empty());
322     host.or(file).unwrap_or_else(|| url.to_string())
323 }
324 
325 /// The favorites: a short, ordered, hand-curated list of places, shown as a
326 /// row of pills in the utility bar. Deliberately not the bookmarks — the
327 /// star is an archive of everything worth finding again; this is the
328 /// handful of sites worth a permanent one-click spot. Insertion order is
329 /// strip order, and the `cce://favorites` page reorders, renames and
330 /// removes.
331 pub struct Favorites {
332     entries: Mutex<Vec<Entry>>,
333     path: PathBuf,
334 }
335 
336 impl Favorites {
337     pub fn load() -> Self {
338         let path = state_dir().join("favorites.tsv");
339         Self { entries: Mutex::new(read_tsv(&path)), path }
340     }
341 
342     /// The strip, in order.
343     pub fn snapshot(&self) -> Vec<Link> {
344         self.entries
345             .lock()
346             .unwrap()
347             .iter()
348             .map(|e| Link { url: e.url.clone(), label: default_label(&e.url, &e.title) })
349             .collect()
350     }
351 
352     pub fn contains(&self, url: &str) -> bool {
353         self.entries.lock().unwrap().iter().any(|e| e.url == url)
354     }
355 
356     /// Add `url` to the end of the strip, or do nothing if it is there.
357     /// Internal pages are refused — a favorite pointing at a blank tab
358     /// helps nobody.
359     pub fn add(&self, url: &str, title: &str) {
360         if url.starts_with("cce:") || url == "about:blank" {
361             return;
362         }
363         let mut entries = self.entries.lock().unwrap();
364         if entries.iter().any(|e| e.url == url) {
365             return;
366         }
367         entries.push(Entry {
368             ts: now(),
369             url: sanitize(url),
370             title: sanitize(&default_label(url, title)),
371         });
372         write_tsv(&self.path, &entries);
373     }
374 
375     /// Add or remove `url`; returns true when it is now a favorite.
376     pub fn toggle(&self, url: &str, title: &str) -> bool {
377         if self.contains(url) {
378             self.remove(url);
379             false
380         } else {
381             self.add(url, title);
382             self.contains(url)
383         }
384     }
385 
386     pub fn remove(&self, url: &str) {
387         let mut entries = self.entries.lock().unwrap();
388         entries.retain(|e| e.url != url);
389         write_tsv(&self.path, &entries);
390     }
391 
392     pub fn rename(&self, url: &str, title: &str) {
393         let mut entries = self.entries.lock().unwrap();
394         if let Some(e) = entries.iter_mut().find(|e| e.url == url) {
395             e.title = sanitize(&default_label(url, title));
396             write_tsv(&self.path, &entries);
397         }
398     }
399 
400     /// Move `url` one place toward the front (`-1`) or the back (`1`).
401     pub fn shift(&self, url: &str, delta: isize) {
402         let mut entries = self.entries.lock().unwrap();
403         let Some(i) = entries.iter().position(|e| e.url == url) else { return };
404         let j = i as isize + delta;
405         if j < 0 || j >= entries.len() as isize {
406             return;
407         }
408         entries.swap(i, j as usize);
409         write_tsv(&self.path, &entries);
410     }
411 
412     fn html(&self) -> String {
413         let entries = self.entries.lock().unwrap();
414         let mut rows = String::new();
415         let last = entries.len().saturating_sub(1);
416         for (i, e) in entries.iter().enumerate() {
417             let enc = url_encode(&e.url);
418             let label = default_label(&e.url, &e.title);
419             // Ordering links; the end pill has nowhere further to go.
420             let up = if i > 0 {
421                 format!("<a class=rm href=\"cce://favorites/up?url={}\">&#9650;</a>", html_escape(&enc))
422             } else {
423                 "<span class=rm>&#9650;</span>".to_string()
424             };
425             let down = if i < last {
426                 format!("<a class=rm href=\"cce://favorites/down?url={}\">&#9660;</a>", html_escape(&enc))
427             } else {
428                 "<span class=rm>&#9660;</span>".to_string()
429             };
430             rows.push_str(&format!(
431                 "<div class=e><span class=w>{up} {down}</span>\
432                  <a href=\"{url}\">{label}</a><span class=u>{url}</span>\
433                  <form action=\"cce://favorites/rename\">\
434                  <input type=hidden name=url value=\"{url}\">\
435                  <input name=title value=\"{label}\" size=18>\
436                  <button>rename</button></form>\
437                  <a class=rm href=\"cce://favorites/remove?url={enc}\">remove</a></div>\n",
438                 url = html_escape(&e.url),
439                 label = html_escape(&label),
440                 enc = html_escape(&enc),
441             ));
442         }
443         let meta = format!(
444             "{} favorites<a href=\"cce://bookmarks\">bookmarks</a>",
445             entries.len()
446         );
447         let body = if entries.is_empty() {
448             "<p class=empty>No favorites yet. Press Ctrl+Shift+D on a page, pick \
449              \"Add to Favorites\" from its right-click menu, or promote a bookmark.</p>"
450                 .to_string()
451         } else {
452             rows
453         };
454         page("Favorites", &meta, &body, FAVORITES_CSS)
455     }
456 }
457 
458 /// The rename form's styling, on top of the shared skeleton.
459 const FAVORITES_CSS: &str = "<style>\
460     .e .w{min-width:3em}\
461     .e form{display:flex;gap:6px;margin:0}\
462     .e input{background:#111214;color:#dcdce1;border:1px solid #2c2d31;border-radius:5px;\
463              padding:2px 6px;font-size:12px;width:9em}\
464     .e button{background:#232427;color:#8a8c92;border:1px solid #2c2d31;border-radius:5px;\
465               padding:2px 8px;font-size:12px;cursor:pointer}\
466     .e button:hover{color:#dcdce1}\
467     .w a{margin-right:4px}\
468     </style>";
469 
470 /// `cce:` scheme: internal pages served straight out of the app.
471 pub struct CceProtocol {
472     pub history: Arc<History>,
473     pub bookmarks: Arc<Bookmarks>,
474     pub favorites: Arc<Favorites>,
475     pub downloads: Arc<crate::downloads::Downloads>,
476     /// Raised by cce://cookies/clear. The handler runs on fetch threads and
477     /// cannot reach Servo, so it flags the request and the app's next pump
478     /// performs the clear through the SiteDataManager.
479     pub clear_cookies: Arc<std::sync::atomic::AtomicBool>,
480 }
481 
482 /// Confirmation page for clearing cookies. Deliberately a page with a link
483 /// rather than a chord that acts immediately: logins persist now, so an
484 /// accidental keystroke would sign the user out of everything.
485 fn cookies_page() -> String {
486     page(
487         "Cookies",
488         "Signed-in sessions live here",
489         "<div class=e><span class=w></span><span class=u>Clearing cookies signs you out of          every site and cannot be undone. Bookmarks and history are untouched.</span></div>         <div class=e><span class=w></span>         <a class=rm href=\"cce://cookies/clear\">Clear all cookies</a></div>",
490         "",
491     )
492 }
493 
494 fn cookies_cleared_page() -> String {
495     page(
496         "Cookies",
497         "Cleared",
498         "<div class=e><span class=w></span><span class=u>All cookies were cleared.          Sites you were signed in to will ask you to sign in again.</span></div>",
499         "",
500     )
501 }
502 
503 impl CceProtocol {
504     /// Route a `cce:` URL to its page. Shared by both engine backends —
505     /// Servo reaches it through `ProtocolHandler` below, WebKit through its
506     /// URI-scheme callback — so the table of pages exists once.
507     ///
508     /// `None` means no such page; the caller turns that into its engine's
509     /// idea of a failed load.
510     pub(crate) fn route(&self, url: &str) -> Option<String> {
511         let full = url.trim_start_matches("cce://");
512         let (path, query) = full.split_once('?').unwrap_or((full, ""));
513         let param = |key: &str| -> Option<String> {
514             url::form_urlencoded::parse(query.as_bytes())
515                 .find(|(k, _)| k == key)
516                 .map(|(_, v)| v.into_owned())
517         };
518         match path.trim_end_matches('/') {
519             "history" => Some(self.history.html()),
520             "history/clear" => {
521                 self.history.clear();
522                 Some(self.history.html())
523             }
524             "bookmarks" => Some(self.bookmarks.html(&self.favorites)),
525             "bookmarks/remove" => {
526                 if let Some(target) = param("url") {
527                     self.bookmarks.remove(&target);
528                 }
529                 Some(self.bookmarks.html(&self.favorites))
530             }
531             "favorites" => Some(self.favorites.html()),
532             // Adding lands on the favorites page so the new pill's place in
533             // the strip is visible right away. A bookmark promoted without a
534             // title in the query keeps the title it was starred with.
535             "favorites/add" => {
536                 if let Some(target) = param("url") {
537                     let title = param("title")
538                         .or_else(|| self.bookmarks.title_of(&target))
539                         .unwrap_or_default();
540                     self.favorites.add(&target, &title);
541                 }
542                 Some(self.favorites.html())
543             }
544             "favorites/remove" => {
545                 if let Some(target) = param("url") {
546                     self.favorites.remove(&target);
547                 }
548                 Some(self.favorites.html())
549             }
550             "favorites/up" | "favorites/down" => {
551                 if let Some(target) = param("url") {
552                     let delta = if path.ends_with("up") { -1 } else { 1 };
553                     self.favorites.shift(&target, delta);
554                 }
555                 Some(self.favorites.html())
556             }
557             "favorites/rename" => {
558                 if let Some(target) = param("url") {
559                     self.favorites.rename(&target, &param("title").unwrap_or_default());
560                 }
561                 Some(self.favorites.html())
562             }
563             "downloads" => Some(self.downloads.html()),
564             "downloads/clear" => {
565                 self.downloads.clear_finished();
566                 Some(self.downloads.html())
567             }
568             "cookies" => Some(cookies_page()),
569             "cookies/clear" => {
570                 self.clear_cookies.store(true, std::sync::atomic::Ordering::SeqCst);
571                 Some(cookies_cleared_page())
572             }
573             _ => None,
574         }
575     }
576 }
577 
578 #[cfg(test)]
579 mod tests {
580     use super::*;
581 
582     /// A favorites store in a scratch directory of this TEST's own.
583     ///
584     /// Named per test rather than shared. The tests run in parallel threads
585     /// of one process, so with a single directory between them each call's
586     /// `remove_dir_all` could take the other's file out from under it
587     /// mid-run — and both wrote the same `favorites.tsv` besides, so the
588     /// round-trip read at the end of `strip_order_...` could have been
589     /// reading the other test's writes.
590     ///
591     /// It does not surface on its own — 40 runs at 8 threads, zero failures,
592     /// so the window is narrow. It is not theoretical either: steering the
593     /// second test's wipe into the first's write-then-read window with a
594     /// 50 ms delay failed it 10 times out of 10, the round-trip read coming
595     /// back empty because the file had been deleted under it. Narrow is the
596     /// argument for fixing it rather than against — a race this rare surfaces
597     /// as one unreproducible CI failure, in a test that failed for a reason
598     /// nowhere in its own body.
599     fn store(name: &str) -> Favorites {
600         let dir = std::env::temp_dir()
601             .join(format!("cce-browser-favs-{}-{}", std::process::id(), name));
602         let _ = fs::remove_dir_all(&dir);
603         Favorites { entries: Mutex::new(Vec::new()), path: dir.join("favorites.tsv") }
604     }
605 
606     #[test]
607     fn labels_fall_back_to_host_then_file_name() {
608         assert_eq!(default_label("https://www.example.com/a", "Example"), "Example");
609         assert_eq!(default_label("https://www.example.com/a", "  "), "example.com");
610         assert_eq!(default_label("file:///home/me/page.html", ""), "page.html");
611         assert_eq!(default_label("about:blank", ""), "about:blank");
612     }
613 
614     #[test]
615     fn strip_order_is_insertion_order_and_shifts_move_one_place() {
616         let f = store("strip-order");
617         f.add("https://a.example/", "A");
618         f.add("https://b.example/", "B");
619         f.add("https://c.example/", "C");
620         f.add("https://b.example/", "again"); // already there: no duplicate
621         let labels = |f: &Favorites| f.snapshot().iter().map(|x| x.label.clone()).collect::<Vec<_>>();
622         assert_eq!(labels(&f), ["A", "B", "C"]);
623         f.shift("https://c.example/", -1);
624         assert_eq!(labels(&f), ["A", "C", "B"]);
625         f.shift("https://a.example/", -1); // already first: stays
626         assert_eq!(labels(&f), ["A", "C", "B"]);
627         f.rename("https://c.example/", "Sea");
628         assert_eq!(labels(&f), ["A", "Sea", "B"]);
629         assert!(!f.toggle("https://a.example/", "A"));
630         assert!(f.toggle("https://d.example/", "D"));
631         assert_eq!(labels(&f), ["Sea", "B", "D"]);
632 
633         // Round-trips through the file.
634         let back = Favorites { entries: Mutex::new(read_tsv(&f.path)), path: f.path.clone() };
635         assert_eq!(back.snapshot(), f.snapshot());
636         let _ = fs::remove_dir_all(f.path.parent().unwrap());
637     }
638 
639     #[test]
640     fn bookmarks_list_newest_first_with_labelled_entries() {
641         let dir = std::env::temp_dir().join(format!("cce-browser-bm-{}", std::process::id()));
642         let _ = fs::remove_dir_all(&dir);
643         let b = Bookmarks { entries: Mutex::new(Vec::new()), path: dir.join("bookmarks.tsv") };
644         b.toggle("https://www.first.example/a", "First");
645         b.toggle("https://second.example/b", "");
646         let seen: Vec<(String, String)> =
647             b.snapshot().into_iter().map(|l| (l.label, l.url)).collect();
648         assert_eq!(seen[0].0, "second.example", "newest first, host as the fallback label");
649         assert_eq!(seen[1].0, "First");
650         let _ = fs::remove_dir_all(&dir);
651     }
652 
653     #[test]
654     fn internal_pages_are_refused() {
655         let f = store("internal-pages");
656         f.add("cce://history", "History");
657         f.add("about:blank", "");
658         assert!(f.snapshot().is_empty());
659         let _ = fs::remove_dir_all(f.path.parent().unwrap());
660     }
661 }
662 
663 #[cfg(feature = "servo")]
664 impl ProtocolHandler for CceProtocol {
665     fn load(
666         &self,
667         request: &mut Request,
668         _done_chan: &mut DoneChannel,
669         _context: &FetchContext,
670     ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
671         let url = request.current_url();
672         let body = self.route(url.as_str());
673         let response = match body {
674             Some(html) => {
675                 let mut response =
676                     Response::new(url, ResourceFetchTiming::new(request.timing_type()));
677                 *response.body.lock() = ResponseBody::Done(html.into_bytes());
678                 response.headers.insert(
679                     http::header::CONTENT_TYPE,
680                     http::HeaderValue::from_static("text/html; charset=utf-8"),
681                 );
682                 response.status = HttpStatus::default();
683                 response
684             }
685             None => Response::network_error(NetworkError::ResourceLoadError(format!(
686                 "no such cce: page: {url}"
687             ))),
688         };
689         Box::pin(std::future::ready(response))
690     }
691 }