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

src/pages.rs (33.8K)

  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     cce_ui::config::cce_state_dir().join("browser")
 43 }
 44 
 45 fn now() -> u64 {
 46     SystemTime::now()
 47         .duration_since(UNIX_EPOCH)
 48         .map(|d| d.as_secs())
 49         .unwrap_or(0)
 50 }
 51 
 52 /// One-line-safe field: the TSV logs separate with tabs and newlines.
 53 fn sanitize(s: &str) -> String {
 54     s.replace(['\t', '\n', '\r'], " ")
 55 }
 56 
 57 pub(crate) fn html_escape(s: &str) -> String {
 58     s.replace('&', "&amp;")
 59         .replace('<', "&lt;")
 60         .replace('>', "&gt;")
 61         .replace('"', "&quot;")
 62 }
 63 
 64 fn read_tsv(path: &PathBuf) -> Vec<Entry> {
 65     let mut entries = Vec::new();
 66     if let Ok(text) = fs::read_to_string(path) {
 67         for line in text.lines() {
 68             let mut parts = line.splitn(3, '\t');
 69             if let (Some(ts), Some(url), Some(title)) = (parts.next(), parts.next(), parts.next())
 70             {
 71                 if let Ok(ts) = ts.parse() {
 72                     entries.push(Entry { ts, url: url.to_string(), title: title.to_string() });
 73                 }
 74             }
 75         }
 76     }
 77     entries
 78 }
 79 
 80 fn write_tsv(path: &PathBuf, entries: &[Entry]) {
 81     if let Some(dir) = path.parent() {
 82         let _ = fs::create_dir_all(dir);
 83     }
 84     let mut out = String::new();
 85     for e in entries {
 86         out.push_str(&format!("{}\t{}\t{}\n", e.ts, e.url, e.title));
 87     }
 88     let _ = fs::write(path, out);
 89 }
 90 
 91 /// Shared page skeleton for the internal pages (dark, DE-toned).
 92 /// `head_extra` lands in <head> (e.g. a refresh tag for live pages).
 93 pub(crate) fn page(title: &str, meta: &str, body: &str, head_extra: &str) -> String {
 94     format!(
 95         "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>{title}</title>{head_extra}<style>\
 96          :root{{color-scheme:dark}}\
 97          body{{background:#1a1b1d;color:#dcdce1;font-family:sans-serif;margin:0;padding:28px 36px}}\
 98          h1{{font-size:20px;font-weight:600;margin:0 0 4px}}\
 99          .meta{{color:#8a8c92;font-size:13px;margin-bottom:20px}}\
100          .meta a{{color:#7fa3d4;text-decoration:none;margin-left:12px}}\
101          .e{{display:flex;gap:14px;padding:7px 10px;border-radius:8px;align-items:baseline}}\
102          .e:hover{{background:#232427}}\
103          .w{{color:#8a8c92;font-size:12px;min-width:11em}}\
104          .e a{{color:#dcdce1;text-decoration:none;white-space:nowrap;overflow:hidden;\
105                text-overflow:ellipsis;max-width:40%}}\
106          .e a:hover{{color:#9fc1ea}}\
107          .u{{color:#6f7177;font-size:12px;white-space:nowrap;overflow:hidden;\
108              text-overflow:ellipsis;flex:1}}\
109          .e a.rm{{color:#6f7177;font-size:12px;max-width:none}}\
110          .e a.rm:hover{{color:#d49b9b}}\
111          .e .tag{{color:#7fa3d4;font-size:12px}}\
112          .empty{{color:#8a8c92}}\
113          </style></head><body>\
114          <h1>{title}</h1>\
115          <div class=meta>{meta}</div>\
116          {body}\
117          <script>for(const el of document.querySelectorAll('[data-ts]')){{\
118          const d=new Date(1000*+el.dataset.ts);\
119          el.textContent=d.toLocaleDateString()+'  '+\
120          d.toLocaleTimeString([],{{hour:'2-digit',minute:'2-digit'}});}}</script>\
121          </body></html>"
122     )
123 }
124 
125 pub struct History {
126     entries: Mutex<Vec<Entry>>,
127     path: PathBuf,
128 }
129 
130 impl History {
131     /// Load the log from the state dir (missing file = empty history).
132     pub fn load() -> Self {
133         let path = state_dir().join("history.tsv");
134         Self { entries: Mutex::new(read_tsv(&path)), path }
135     }
136 
137     /// Record a completed page load. Internal pages and immediate
138     /// duplicates (reload spam) are skipped.
139     pub fn record(&self, url: &str, title: &str) {
140         if url.starts_with("cce:") || url == "about:blank" {
141             return;
142         }
143         let mut entries = self.entries.lock().unwrap();
144         if entries.last().is_some_and(|last| last.url == url) {
145             return;
146         }
147         let entry = Entry { ts: now(), url: sanitize(url), title: sanitize(title) };
148         if let Some(dir) = self.path.parent() {
149             let _ = fs::create_dir_all(dir);
150         }
151         if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&self.path) {
152             let _ = writeln!(f, "{}\t{}\t{}", entry.ts, entry.url, entry.title);
153         }
154         entries.push(entry);
155     }
156 
157     pub fn clear(&self) {
158         self.entries.lock().unwrap().clear();
159         let _ = fs::write(&self.path, "");
160     }
161 
162     fn html(&self) -> String {
163         let entries = self.entries.lock().unwrap();
164         let mut rows = String::new();
165         for e in entries.iter().rev().take(RENDER_CAP) {
166             let title = if e.title.trim().is_empty() { &e.url } else { &e.title };
167             rows.push_str(&format!(
168                 "<div class=e><span class=w data-ts=\"{}\"></span>\
169                  <a href=\"{}\">{}</a><span class=u>{}</span></div>\n",
170                 e.ts,
171                 html_escape(&e.url),
172                 html_escape(title),
173                 html_escape(&e.url),
174             ));
175         }
176         let meta = format!(
177             "{} entries<a href=\"cce://history/clear\">clear</a>",
178             entries.len()
179         );
180         let body = if entries.is_empty() {
181             "<p class=empty>No history yet.</p>".to_string()
182         } else {
183             rows
184         };
185         page("History", &meta, &body, "")
186     }
187 }
188 
189 pub struct Bookmarks {
190     entries: Mutex<Vec<Entry>>,
191     path: PathBuf,
192     /// The Raindrop sync's last word, for the page to show. `None` while the
193     /// sync is off.
194     sync_note: Mutex<Option<SyncNote>>,
195     /// Raised by the page's sync links for the sync worker to pick up — the
196     /// handler cannot do network work itself. 1 = sync now, 2 = run the
197     /// refused pass anyway.
198     sync_request: std::sync::atomic::AtomicU8,
199 }
200 
201 /// What the Raindrop sync last said, shown on `cce://bookmarks`.
202 #[derive(Clone, Debug, Default)]
203 pub struct SyncNote {
204     pub text: String,
205     /// When it was said, for "3m ago".
206     pub at: u64,
207     /// Set when a pass was refused: the code the "sync anyway" link must
208     /// carry. Random per refusal, so a page that links to `cce://` cannot
209     /// force a mass deletion — it cannot read this page to learn the code.
210     pub force_code: Option<u64>,
211 }
212 
213 pub const SYNC_NOW: u8 = 1;
214 pub const SYNC_FORCE: u8 = 2;
215 
216 impl Bookmarks {
217     pub fn load() -> Self {
218         Self::at(state_dir().join("bookmarks.tsv"))
219     }
220 
221     pub(crate) fn at(path: PathBuf) -> Self {
222         Self {
223             entries: Mutex::new(read_tsv(&path)),
224             path,
225             sync_note: Mutex::new(None),
226             sync_request: std::sync::atomic::AtomicU8::new(0),
227         }
228     }
229 
230     /// Edit every row at once, under the lock, and write the file — the
231     /// Raindrop sync's way in, so a whole pass lands as one change that no
232     /// star or remove can interleave with. Fields are sanitized on the way
233     /// back: a title from elsewhere can hold a tab or a newline, and this
234     /// format has no escaping.
235     pub fn edit_rows<R>(&self, f: impl FnOnce(&mut Vec<(u64, String, String)>) -> R) -> R {
236         let mut entries = self.entries.lock().unwrap();
237         let mut rows: Vec<_> =
238             entries.iter().map(|e| (e.ts, e.url.clone(), e.title.clone())).collect();
239         let out = f(&mut rows);
240         *entries = rows
241             .into_iter()
242             .map(|(ts, url, title)| Entry { ts, url: sanitize(&url), title: sanitize(&title) })
243             .collect();
244         write_tsv(&self.path, &entries);
245         out
246     }
247 
248     pub fn set_sync_note(&self, note: Option<SyncNote>) {
249         *self.sync_note.lock().unwrap() = note;
250     }
251 
252     /// The "sync anyway" code on the page right now, if a refusal is showing.
253     pub fn sync_force_code(&self) -> Option<u64> {
254         self.sync_note.lock().unwrap().as_ref().and_then(|n| n.force_code)
255     }
256 
257     /// The page's pending sync request, cleared as it is read.
258     pub fn take_sync_request(&self) -> u8 {
259         self.sync_request.swap(0, std::sync::atomic::Ordering::SeqCst)
260     }
261 
262     /// The sync status line and its links, or nothing while the sync is off.
263     fn sync_html(&self) -> String {
264         let Some(note) = self.sync_note.lock().unwrap().clone() else {
265             return String::new();
266         };
267         let ago = now().saturating_sub(note.at);
268         let ago = match ago {
269             0..=59 => "just now".to_string(),
270             60..=3599 => format!("{}m ago", ago / 60),
271             _ => format!("{}h ago", ago / 3600),
272         };
273         let force = note
274             .force_code
275             .map(|c| format!(" <a class=rm href=\"cce://bookmarks/sync-force?code={c}\">sync anyway</a>"))
276             .unwrap_or_default();
277         format!(
278             "<div class=e><span class=w></span><span class=u>Raindrop: {} · {ago}</span>\
279              <a class=rm href=\"cce://bookmarks/sync\">sync now</a>{force}</div>\n",
280             html_escape(&note.text)
281         )
282     }
283 
284     pub fn contains(&self, url: &str) -> bool {
285         self.entries.lock().unwrap().iter().any(|e| e.url == url)
286     }
287 
288     /// Add or remove a bookmark for `url`; returns true when it is now
289     /// bookmarked.
290     pub fn toggle(&self, url: &str, title: &str) -> bool {
291         if url.starts_with("cce:") || url == "about:blank" {
292             return false;
293         }
294         let mut entries = self.entries.lock().unwrap();
295         let added = if let Some(i) = entries.iter().position(|e| e.url == url) {
296             entries.remove(i);
297             false
298         } else {
299             entries.push(Entry { ts: now(), url: sanitize(url), title: sanitize(title) });
300             true
301         };
302         write_tsv(&self.path, &entries);
303         added
304     }
305 
306     pub fn remove(&self, url: &str) {
307         let mut entries = self.entries.lock().unwrap();
308         entries.retain(|e| e.url != url);
309         write_tsv(&self.path, &entries);
310     }
311 
312     /// Every bookmark as stored — `(ts, url, title)` in file order — for the
313     /// Raindrop sync, which needs the timestamps the menu does not.
314     pub fn rows(&self) -> Vec<(u64, String, String)> {
315         self.entries
316             .lock()
317             .unwrap()
318             .iter()
319             .map(|e| (e.ts, e.url.clone(), e.title.clone()))
320             .collect()
321     }
322 
323     /// The bookmarks as the chrome's menu lists them: newest first, the
324     /// same order the `cce://bookmarks` page renders.
325     pub fn snapshot(&self) -> Vec<Link> {
326         self.entries
327             .lock()
328             .unwrap()
329             .iter()
330             .rev()
331             .map(|e| Link { url: e.url.clone(), label: default_label(&e.url, &e.title) })
332             .collect()
333     }
334 
335     /// The title a bookmark was saved with, for promoting it to a favorite
336     /// from the bookmarks page without re-fetching anything.
337     pub fn title_of(&self, url: &str) -> Option<String> {
338         self.entries
339             .lock()
340             .unwrap()
341             .iter()
342             .find(|e| e.url == url)
343             .map(|e| e.title.clone())
344     }
345 
346     fn html(&self, favorites: &Favorites) -> String {
347         let entries = self.entries.lock().unwrap();
348         let mut rows = String::new();
349         for e in entries.iter().rev() {
350             let title = if e.title.trim().is_empty() { &e.url } else { &e.title };
351             let enc = url_encode(&e.url);
352             // A bookmark that is already a favorite says so instead of
353             // offering to add it twice.
354             let fav = if favorites.contains(&e.url) {
355                 "<span class=tag>favorite</span>".to_string()
356             } else {
357                 format!("<a class=rm href=\"cce://favorites/add?url={}\">favorite</a>", html_escape(&enc))
358             };
359             rows.push_str(&format!(
360                 "<div class=e><span class=w data-ts=\"{}\"></span>\
361                  <a href=\"{}\">{}</a><span class=u>{}</span>{fav}\
362                  <a class=rm href=\"cce://bookmarks/remove?url={}\">remove</a></div>\n",
363                 e.ts,
364                 html_escape(&e.url),
365                 html_escape(title),
366                 html_escape(&e.url),
367                 html_escape(&enc),
368             ));
369         }
370         let meta = format!(
371             "{} bookmarks<a href=\"cce://favorites\">favorites</a>",
372             entries.len()
373         );
374         let body = if entries.is_empty() {
375             "<p class=empty>No bookmarks yet. Star a page or press Ctrl+D.</p>".to_string()
376         } else {
377             rows
378         };
379         drop(entries);
380         let body = format!("{}{body}", self.sync_html());
381         page("Bookmarks", &meta, &body, "")
382     }
383 }
384 
385 fn url_encode(s: &str) -> String {
386     url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
387 }
388 
389 /// One saved place as the chrome shows it: a label and where it goes.
390 /// Shared by the favorites strip's pills and the bookmarks menu's rows —
391 /// both want a display label, not a raw URL.
392 #[derive(Clone, Debug, PartialEq)]
393 pub struct Link {
394     pub url: String,
395     pub label: String,
396 }
397 
398 /// The label a favorite gets when it is added: the page title, or — for an
399 /// untitled page — the host with any `www.` shorn off (the file name, for
400 /// a `file:` URL, which has no host), so a pill never reads as a full URL.
401 fn default_label(url: &str, title: &str) -> String {
402     let title = title.trim();
403     if !title.is_empty() {
404         return title.to_string();
405     }
406     let Ok(u) = url::Url::parse(url) else { return url.to_string() };
407     let host = u
408         .host_str()
409         .map(|h| h.trim_start_matches("www.").to_string())
410         .filter(|h| !h.is_empty());
411     let file = u
412         .path_segments()
413         .and_then(|mut segs| segs.next_back().map(str::to_string))
414         .filter(|f| !f.is_empty());
415     host.or(file).unwrap_or_else(|| url.to_string())
416 }
417 
418 /// The favorites page's move-up mark: cce-icons' `svg/arrow-up.svg`, copied
419 /// inline (an `include_str!` of the sibling crate would break a standalone
420 /// clone) with its `#ffffff` made `currentColor`, so it takes the link's
421 /// colour — dim, the hover red, the disabled end's — as the text did.
422 const ARROW_UP_SVG: &str = "<svg viewBox=\"0 0 40 40\" width=\"1em\" height=\"1em\" \
423      style=\"vertical-align:-.15em\" aria-label=\"up\" xmlns=\"http://www.w3.org/2000/svg\">\
424      <g fill=\"currentColor\" transform=\"rotate(-90 20 20)\">\
425      <rect x=\"4\" y=\"17\" width=\"18\" height=\"6\" rx=\"3\"/>\
426      <path d=\"M22 11 L33 20 L22 29 Z\" stroke=\"currentColor\" stroke-width=\"5\" stroke-linejoin=\"round\"/>\
427      </g></svg>";
428 
429 /// The move-down mark: cce-icons' `svg/arrow-down.svg`, copied as
430 /// [`ARROW_UP_SVG`] is.
431 const ARROW_DOWN_SVG: &str = "<svg viewBox=\"0 0 40 40\" width=\"1em\" height=\"1em\" \
432      style=\"vertical-align:-.15em\" aria-label=\"down\" xmlns=\"http://www.w3.org/2000/svg\">\
433      <g fill=\"currentColor\" transform=\"rotate(90 20 20)\">\
434      <rect x=\"4\" y=\"17\" width=\"18\" height=\"6\" rx=\"3\"/>\
435      <path d=\"M22 11 L33 20 L22 29 Z\" stroke=\"currentColor\" stroke-width=\"5\" stroke-linejoin=\"round\"/>\
436      </g></svg>";
437 
438 /// The favorites: a short, ordered, hand-curated list of places, shown as a
439 /// row of pills in the utility bar. Deliberately not the bookmarks — the
440 /// star is an archive of everything worth finding again; this is the
441 /// handful of sites worth a permanent one-click spot. Insertion order is
442 /// strip order, and the `cce://favorites` page reorders, renames and
443 /// removes.
444 pub struct Favorites {
445     entries: Mutex<Vec<Entry>>,
446     path: PathBuf,
447 }
448 
449 impl Favorites {
450     pub fn load() -> Self {
451         let path = state_dir().join("favorites.tsv");
452         Self { entries: Mutex::new(read_tsv(&path)), path }
453     }
454 
455     /// The strip, in order.
456     pub fn snapshot(&self) -> Vec<Link> {
457         self.entries
458             .lock()
459             .unwrap()
460             .iter()
461             .map(|e| Link { url: e.url.clone(), label: default_label(&e.url, &e.title) })
462             .collect()
463     }
464 
465     pub fn contains(&self, url: &str) -> bool {
466         self.entries.lock().unwrap().iter().any(|e| e.url == url)
467     }
468 
469     /// Add `url` to the end of the strip, or do nothing if it is there.
470     /// Internal pages are refused — a favorite pointing at a blank tab
471     /// helps nobody.
472     pub fn add(&self, url: &str, title: &str) {
473         if url.starts_with("cce:") || url == "about:blank" {
474             return;
475         }
476         let mut entries = self.entries.lock().unwrap();
477         if entries.iter().any(|e| e.url == url) {
478             return;
479         }
480         entries.push(Entry {
481             ts: now(),
482             url: sanitize(url),
483             title: sanitize(&default_label(url, title)),
484         });
485         write_tsv(&self.path, &entries);
486     }
487 
488     /// Add or remove `url`; returns true when it is now a favorite.
489     pub fn toggle(&self, url: &str, title: &str) -> bool {
490         if self.contains(url) {
491             self.remove(url);
492             false
493         } else {
494             self.add(url, title);
495             self.contains(url)
496         }
497     }
498 
499     pub fn remove(&self, url: &str) {
500         let mut entries = self.entries.lock().unwrap();
501         entries.retain(|e| e.url != url);
502         write_tsv(&self.path, &entries);
503     }
504 
505     pub fn rename(&self, url: &str, title: &str) {
506         let mut entries = self.entries.lock().unwrap();
507         if let Some(e) = entries.iter_mut().find(|e| e.url == url) {
508             e.title = sanitize(&default_label(url, title));
509             write_tsv(&self.path, &entries);
510         }
511     }
512 
513     /// Move `url` one place toward the front (`-1`) or the back (`1`).
514     pub fn shift(&self, url: &str, delta: isize) {
515         let mut entries = self.entries.lock().unwrap();
516         let Some(i) = entries.iter().position(|e| e.url == url) else { return };
517         let j = i as isize + delta;
518         if j < 0 || j >= entries.len() as isize {
519             return;
520         }
521         entries.swap(i, j as usize);
522         write_tsv(&self.path, &entries);
523     }
524 
525     fn html(&self) -> String {
526         let entries = self.entries.lock().unwrap();
527         let mut rows = String::new();
528         let last = entries.len().saturating_sub(1);
529         for (i, e) in entries.iter().enumerate() {
530             let enc = url_encode(&e.url);
531             let label = default_label(&e.url, &e.title);
532             // Ordering links; the end pill has nowhere further to go.
533             let up = if i > 0 {
534                 format!("<a class=rm href=\"cce://favorites/up?url={}\">{ARROW_UP_SVG}</a>", html_escape(&enc))
535             } else {
536                 format!("<span class=rm>{ARROW_UP_SVG}</span>")
537             };
538             let down = if i < last {
539                 format!("<a class=rm href=\"cce://favorites/down?url={}\">{ARROW_DOWN_SVG}</a>", html_escape(&enc))
540             } else {
541                 format!("<span class=rm>{ARROW_DOWN_SVG}</span>")
542             };
543             rows.push_str(&format!(
544                 "<div class=e><span class=w>{up} {down}</span>\
545                  <a href=\"{url}\">{label}</a><span class=u>{url}</span>\
546                  <form action=\"cce://favorites/rename\">\
547                  <input type=hidden name=url value=\"{url}\">\
548                  <input name=title value=\"{label}\" size=18>\
549                  <button>rename</button></form>\
550                  <a class=rm href=\"cce://favorites/remove?url={enc}\">remove</a></div>\n",
551                 url = html_escape(&e.url),
552                 label = html_escape(&label),
553                 enc = html_escape(&enc),
554             ));
555         }
556         let meta = format!(
557             "{} favorites<a href=\"cce://bookmarks\">bookmarks</a>",
558             entries.len()
559         );
560         let body = if entries.is_empty() {
561             "<p class=empty>No favorites yet. Press Ctrl+Shift+D on a page, pick \
562              \"Add to Favorites\" from its right-click menu, or promote a bookmark.</p>"
563                 .to_string()
564         } else {
565             rows
566         };
567         page("Favorites", &meta, &body, FAVORITES_CSS)
568     }
569 }
570 
571 /// The rename form's styling, on top of the shared skeleton.
572 const FAVORITES_CSS: &str = "<style>\
573     .e .w{min-width:3em}\
574     .e form{display:flex;gap:6px;margin:0}\
575     .e input{background:#111214;color:#dcdce1;border:1px solid #2c2d31;border-radius:5px;\
576              padding:2px 6px;font-size:12px;width:9em}\
577     .e button{background:#232427;color:#8a8c92;border:1px solid #2c2d31;border-radius:5px;\
578               padding:2px 8px;font-size:12px;cursor:pointer}\
579     .e button:hover{color:#dcdce1}\
580     .w a{margin-right:4px}\
581     </style>";
582 
583 /// `cce:` scheme: internal pages served straight out of the app.
584 pub struct CceProtocol {
585     pub history: Arc<History>,
586     pub bookmarks: Arc<Bookmarks>,
587     pub favorites: Arc<Favorites>,
588     pub downloads: Arc<crate::downloads::Downloads>,
589     /// Raised by cce://cookies/clear. The handler runs on fetch threads and
590     /// cannot reach Servo, so it flags the request and the app's next pump
591     /// performs the clear through the SiteDataManager.
592     pub clear_cookies: Arc<std::sync::atomic::AtomicBool>,
593 }
594 
595 /// Confirmation page for clearing cookies. Deliberately a page with a link
596 /// rather than a chord that acts immediately: logins persist now, so an
597 /// accidental keystroke would sign the user out of everything.
598 fn cookies_page() -> String {
599     page(
600         "Cookies",
601         "Signed-in sessions live here",
602         "<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>",
603         "",
604     )
605 }
606 
607 fn cookies_cleared_page() -> String {
608     page(
609         "Cookies",
610         "Cleared",
611         "<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>",
612         "",
613     )
614 }
615 
616 impl CceProtocol {
617     /// Route a `cce:` URL to its page. Shared by both engine backends —
618     /// Servo reaches it through `ProtocolHandler` below, WebKit through its
619     /// URI-scheme callback — so the table of pages exists once.
620     ///
621     /// `None` means no such page; the caller turns that into its engine's
622     /// idea of a failed load.
623     pub(crate) fn route(&self, url: &str) -> Option<String> {
624         let full = url.trim_start_matches("cce://");
625         let (path, query) = full.split_once('?').unwrap_or((full, ""));
626         let param = |key: &str| -> Option<String> {
627             url::form_urlencoded::parse(query.as_bytes())
628                 .find(|(k, _)| k == key)
629                 .map(|(_, v)| v.into_owned())
630         };
631         match path.trim_end_matches('/') {
632             "history" => Some(self.history.html()),
633             "history/clear" => {
634                 self.history.clear();
635                 Some(self.history.html())
636             }
637             "bookmarks" => Some(self.bookmarks.html(&self.favorites)),
638             "bookmarks/remove" => {
639                 if let Some(target) = param("url") {
640                     self.bookmarks.remove(&target);
641                 }
642                 Some(self.bookmarks.html(&self.favorites))
643             }
644             "bookmarks/sync" => {
645                 self.bookmarks.sync_request.store(SYNC_NOW, std::sync::atomic::Ordering::SeqCst);
646                 Some(self.bookmarks.html(&self.favorites))
647             }
648             // Only with the code the refusal put on this page: forcing a pass
649             // the guard refused is how a mass deletion happens on purpose, and
650             // it must not happen because some web page linked here.
651             "bookmarks/sync-force" => {
652                 let expected = self.bookmarks.sync_note.lock().unwrap().as_ref().and_then(|n| n.force_code);
653                 let given = param("code").and_then(|c| c.parse::<u64>().ok());
654                 if expected.is_some() && given == expected {
655                     self.bookmarks.sync_request.store(SYNC_FORCE, std::sync::atomic::Ordering::SeqCst);
656                 }
657                 Some(self.bookmarks.html(&self.favorites))
658             }
659             "favorites" => Some(self.favorites.html()),
660             // Adding lands on the favorites page so the new pill's place in
661             // the strip is visible right away. A bookmark promoted without a
662             // title in the query keeps the title it was starred with.
663             "favorites/add" => {
664                 if let Some(target) = param("url") {
665                     let title = param("title")
666                         .or_else(|| self.bookmarks.title_of(&target))
667                         .unwrap_or_default();
668                     self.favorites.add(&target, &title);
669                 }
670                 Some(self.favorites.html())
671             }
672             "favorites/remove" => {
673                 if let Some(target) = param("url") {
674                     self.favorites.remove(&target);
675                 }
676                 Some(self.favorites.html())
677             }
678             "favorites/up" | "favorites/down" => {
679                 if let Some(target) = param("url") {
680                     let delta = if path.ends_with("up") { -1 } else { 1 };
681                     self.favorites.shift(&target, delta);
682                 }
683                 Some(self.favorites.html())
684             }
685             "favorites/rename" => {
686                 if let Some(target) = param("url") {
687                     self.favorites.rename(&target, &param("title").unwrap_or_default());
688                 }
689                 Some(self.favorites.html())
690             }
691             "downloads" => Some(self.downloads.html()),
692             "downloads/clear" => {
693                 self.downloads.clear_finished();
694                 Some(self.downloads.html())
695             }
696             "cookies" => Some(cookies_page()),
697             "cookies/clear" => {
698                 self.clear_cookies.store(true, std::sync::atomic::Ordering::SeqCst);
699                 Some(cookies_cleared_page())
700             }
701             _ => None,
702         }
703     }
704 }
705 
706 #[cfg(test)]
707 mod tests {
708     use super::*;
709 
710     #[test]
711     fn sync_links_raise_requests_and_force_needs_the_code() {
712         let dir = std::env::temp_dir().join(format!("cce-browser-sync-{}", std::process::id()));
713         let _ = std::fs::remove_dir_all(&dir);
714         let proto = CceProtocol {
715             history: Arc::new(History { entries: Mutex::new(Vec::new()), path: dir.join("history.tsv") }),
716             bookmarks: Arc::new(Bookmarks::at(dir.join("bookmarks.tsv"))),
717             favorites: Arc::new(Favorites { entries: Mutex::new(Vec::new()), path: dir.join("favorites.tsv") }),
718             downloads: Arc::new(crate::downloads::Downloads::default()),
719             clear_cookies: Arc::new(std::sync::atomic::AtomicBool::new(false)),
720         };
721         let b = &proto.bookmarks;
722         // Off: no status line at all.
723         assert!(!proto.route("cce://bookmarks").unwrap().contains("Raindrop:"));
724 
725         b.set_sync_note(Some(SyncNote { text: "synced".into(), at: now(), force_code: None }));
726         assert!(proto.route("cce://bookmarks").unwrap().contains("Raindrop: synced · just now"));
727         proto.route("cce://bookmarks/sync");
728         assert_eq!(b.take_sync_request(), SYNC_NOW);
729         assert_eq!(b.take_sync_request(), 0, "a request is taken once");
730 
731         // Forcing needs a refusal, and its code.
732         proto.route("cce://bookmarks/sync-force?code=7");
733         assert_eq!(b.take_sync_request(), 0, "nothing was refused");
734         b.set_sync_note(Some(SyncNote { text: "refused".into(), at: now(), force_code: Some(42) }));
735         assert!(proto.route("cce://bookmarks").unwrap().contains("sync-force?code=42"));
736         proto.route("cce://bookmarks/sync-force?code=7");
737         assert_eq!(b.take_sync_request(), 0, "the wrong code forces nothing");
738         proto.route("cce://bookmarks/sync-force?code=42");
739         assert_eq!(b.take_sync_request(), SYNC_FORCE);
740 
741         // A pass's edit sanitizes what it writes.
742         b.edit_rows(|rows| rows.push((1, "https://a.test/".into(), "two\nlines\there".into())));
743         assert_eq!(b.rows(), vec![(1, "https://a.test/".to_string(), "two lines here".to_string())]);
744         let _ = std::fs::remove_dir_all(&dir);
745     }
746 
747     /// A favorites store in a scratch directory of this TEST's own.
748     ///
749     /// Named per test rather than shared. The tests run in parallel threads
750     /// of one process, so with a single directory between them each call's
751     /// `remove_dir_all` could take the other's file out from under it
752     /// mid-run — and both wrote the same `favorites.tsv` besides, so the
753     /// round-trip read at the end of `strip_order_...` could have been
754     /// reading the other test's writes.
755     ///
756     /// It does not surface on its own — 40 runs at 8 threads, zero failures,
757     /// so the window is narrow. It is not theoretical either: steering the
758     /// second test's wipe into the first's write-then-read window with a
759     /// 50 ms delay failed it 10 times out of 10, the round-trip read coming
760     /// back empty because the file had been deleted under it. Narrow is the
761     /// argument for fixing it rather than against — a race this rare surfaces
762     /// as one unreproducible CI failure, in a test that failed for a reason
763     /// nowhere in its own body.
764     fn store(name: &str) -> Favorites {
765         let dir = std::env::temp_dir()
766             .join(format!("cce-browser-favs-{}-{}", std::process::id(), name));
767         let _ = fs::remove_dir_all(&dir);
768         Favorites { entries: Mutex::new(Vec::new()), path: dir.join("favorites.tsv") }
769     }
770 
771     #[test]
772     fn labels_fall_back_to_host_then_file_name() {
773         assert_eq!(default_label("https://www.example.com/a", "Example"), "Example");
774         assert_eq!(default_label("https://www.example.com/a", "  "), "example.com");
775         assert_eq!(default_label("file:///home/me/page.html", ""), "page.html");
776         assert_eq!(default_label("about:blank", ""), "about:blank");
777     }
778 
779     #[test]
780     fn strip_order_is_insertion_order_and_shifts_move_one_place() {
781         let f = store("strip-order");
782         f.add("https://a.example/", "A");
783         f.add("https://b.example/", "B");
784         f.add("https://c.example/", "C");
785         f.add("https://b.example/", "again"); // already there: no duplicate
786         let labels = |f: &Favorites| f.snapshot().iter().map(|x| x.label.clone()).collect::<Vec<_>>();
787         assert_eq!(labels(&f), ["A", "B", "C"]);
788         f.shift("https://c.example/", -1);
789         assert_eq!(labels(&f), ["A", "C", "B"]);
790         f.shift("https://a.example/", -1); // already first: stays
791         assert_eq!(labels(&f), ["A", "C", "B"]);
792         f.rename("https://c.example/", "Sea");
793         assert_eq!(labels(&f), ["A", "Sea", "B"]);
794         assert!(!f.toggle("https://a.example/", "A"));
795         assert!(f.toggle("https://d.example/", "D"));
796         assert_eq!(labels(&f), ["Sea", "B", "D"]);
797 
798         // Round-trips through the file.
799         let back = Favorites { entries: Mutex::new(read_tsv(&f.path)), path: f.path.clone() };
800         assert_eq!(back.snapshot(), f.snapshot());
801         let _ = fs::remove_dir_all(f.path.parent().unwrap());
802     }
803 
804     #[test]
805     fn bookmarks_list_newest_first_with_labelled_entries() {
806         let dir = std::env::temp_dir().join(format!("cce-browser-bm-{}", std::process::id()));
807         let _ = fs::remove_dir_all(&dir);
808         let b = Bookmarks::at(dir.join("bookmarks.tsv"));
809         b.toggle("https://www.first.example/a", "First");
810         b.toggle("https://second.example/b", "");
811         let seen: Vec<(String, String)> =
812             b.snapshot().into_iter().map(|l| (l.label, l.url)).collect();
813         assert_eq!(seen[0].0, "second.example", "newest first, host as the fallback label");
814         assert_eq!(seen[1].0, "First");
815         let _ = fs::remove_dir_all(&dir);
816     }
817 
818     #[test]
819     fn internal_pages_are_refused() {
820         let f = store("internal-pages");
821         f.add("cce://history", "History");
822         f.add("about:blank", "");
823         assert!(f.snapshot().is_empty());
824         let _ = fs::remove_dir_all(f.path.parent().unwrap());
825     }
826 }
827 
828 #[cfg(feature = "servo")]
829 impl ProtocolHandler for CceProtocol {
830     fn load(
831         &self,
832         request: &mut Request,
833         _done_chan: &mut DoneChannel,
834         _context: &FetchContext,
835     ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
836         let url = request.current_url();
837         let body = self.route(url.as_str());
838         let response = match body {
839             Some(html) => {
840                 let mut response =
841                     Response::new(url, ResourceFetchTiming::new(request.timing_type()));
842                 *response.body.lock() = ResponseBody::Done(html.into_bytes());
843                 response.headers.insert(
844                     http::header::CONTENT_TYPE,
845                     http::HeaderValue::from_static("text/html; charset=utf-8"),
846                 );
847                 response.status = HttpStatus::default();
848                 response
849             }
850             None => Response::network_error(NetworkError::ResourceLoadError(format!(
851                 "no such cce: page: {url}"
852             ))),
853         };
854         Box::pin(std::future::ready(response))
855     }
856 }