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('&', "&")
63 .replace('<', "<")
64 .replace('>', ">")
65 .replace('"', """)
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={}\">▲</a>", html_escape(&enc))
422 } else {
423 "<span class=rm>▲</span>".to_string()
424 };
425 let down = if i < last {
426 format!("<a class=rm href=\"cce://favorites/down?url={}\">▼</a>", html_escape(&enc))
427 } else {
428 "<span class=rm>▼</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, ¶m("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 }