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('&', "&")
59 .replace('<', "<")
60 .replace('>', ">")
61 .replace('"', """)
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(¬e.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, ¶m("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 }