1use serde::{Deserialize, Serialize};
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
9use std::time::{Duration, Instant};
10use std::sync::{Arc, Mutex};
11use tauri::menu::{Menu, MenuItem, Submenu};
12use tauri::webview::DownloadEvent;
13use tauri::{Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
14use yeda_core::fulltext::{self, Settings};
15
16#[derive(Deserialize)]
17pub struct Job {
18 pub downloads: PathBuf,
20 pub settings: Settings,
21 pub papers: Vec<Paper>,
22}
23
24#[derive(Deserialize, Clone)]
25pub struct Paper {
26 pub key: String,
27 pub doi: String,
28 pub file: String,
30}
31
32#[derive(Serialize)]
33#[serde(untagged)]
34pub enum Outcome {
35 Saved { key: String, file: PathBuf },
36 Failed { key: String, error: String },
37 Visited { key: String, page: String },
39}
40
41enum Event {
42 Loaded(String),
43 NewWindow(String),
45 Title(String),
47 Downloaded(PathBuf),
48 NextProxy,
49 Skip,
50 Stop,
51}
52
53enum Step {
55 Done(Outcome),
56 NextProxy,
57 Stop,
58}
59
60fn report(outcome: &Outcome) {
61 let mut out = std::io::stdout().lock();
62 let _ = serde_json::to_writer(&mut out, outcome);
63 let _ = writeln!(out);
64 let _ = out.flush();
65}
66
67const HTML: &str = "yeda:html ";
68
69fn report_script(proxy: Option<&str>) -> String {
72 let host = serde_json::to_string(&proxy.and_then(fulltext::proxy_host)).unwrap();
73 let proxy = serde_json::to_string(&proxy).unwrap();
74 format!(
75 r#"(() => {{
76 const m = document.querySelector('meta[name="citation_pdf_url"]');
77 let url = m && m.content ? new URL(m.content, location.href).href : '';
78 const proxy = {proxy}, host = {host};
79 if (url && host && location.hostname.endsWith('.' + host) && !new URL(url).hostname.endsWith('.' + host)) url = proxy.replace('{{url}}', url);
80 document.title = '{HTML}' + location.href + ' ' + url;
81}})()"#
82 )
83}
84
85fn download_in_page(window: &WebviewWindow, url: &str) {
88 let url = serde_json::to_string(url).unwrap();
89 let _ = window.eval(format!("(() => {{ const a = document.createElement('a'); a.href = {url}; a.download = ''; document.body.appendChild(a); a.click(); }})()"));
90}
91
92fn download_natively(window: &WebviewWindow, url: &str) {
94 #[cfg(target_os = "linux")]
96 {
97 use webkit2gtk::WebViewExt;
98 let url = url.to_string();
99 let _ = window.with_webview(move |w| drop(w.inner().download_uri(&url)));
100 }
101 #[cfg(not(target_os = "linux"))]
102 let _ = (window, url);
103}
104
105pub fn run(job: Job) {
106 let (tx, rx) = mpsc::channel::<Event>();
107 let current: Arc<Mutex<String>> = Arc::default();
109 let mut context = tauri::generate_context!();
110 context.config_mut().app.windows.clear();
112 let downloads = job.downloads.clone();
113 tauri::Builder::default()
114 .setup(move |app| {
115 std::fs::create_dir_all(&downloads)?;
116 let fetch = Submenu::with_items(
117 app,
118 "Fetch",
119 true,
120 &[&MenuItem::with_id(app, "skip", "Skip this paper", true, None::<&str>)?, &MenuItem::with_id(app, "stop", "Stop", true, None::<&str>)?],
121 )?;
122 if job.settings.proxies.len() > 1 {
123 fetch.prepend(&MenuItem::with_id(app, "next", "Try the next proxy", true, None::<&str>)?)?;
124 }
125 let menu = Menu::with_items(app, &[&fetch])?;
126 let (load_tx, dl_tx, menu_tx, title_tx, new_tx) = (tx.clone(), tx.clone(), tx.clone(), tx.clone(), tx.clone());
127 let target = current.clone();
128 let dir = downloads.clone();
129 let window = WebviewWindowBuilder::new(app, "fetch", WebviewUrl::External("about:blank".parse().unwrap()))
130 .title("yeda fetch")
131 .inner_size(1100.0, 850.0)
132 .menu(menu)
133 .data_directory(app.path().app_data_dir()?.join("fetch-browser"))
135 .on_document_title_changed(move |_, title| drop(title_tx.send(Event::Title(title))))
136 .on_new_window(move |url, _| {
137 let _ = new_tx.send(Event::NewWindow(url.to_string()));
138 tauri::webview::NewWindowResponse::Deny
139 })
140 .on_page_load(move |_, payload| {
141 if payload.event() == tauri::webview::PageLoadEvent::Finished {
142 let _ = load_tx.send(Event::Loaded(payload.url().to_string()));
143 }
144 })
145 .on_download(move |_, event| {
146 match event {
147 DownloadEvent::Requested { destination, .. } => {
148 *destination = dir.join(format!("{}.part", target.lock().unwrap()));
149 }
150 DownloadEvent::Finished { .. } => {
153 let _ = dl_tx.send(Event::Downloaded(dir.join(format!("{}.part", target.lock().unwrap()))));
154 }
155 _ => {}
156 }
157 true
158 })
159 .build()?;
160 app.on_menu_event(move |_, e| {
161 let _ = menu_tx.send(match e.id().as_ref() {
162 "next" => Event::NextProxy,
163 "skip" => Event::Skip,
164 _ => Event::Stop,
165 });
166 });
167 let close_tx = tx.clone();
168 window.on_window_event(move |e| {
169 if let tauri::WindowEvent::CloseRequested { .. } = e {
170 let _ = close_tx.send(Event::Stop);
171 }
172 });
173 let handle = app.handle().clone();
174 std::thread::spawn(move || {
175 drive(&window, &job, &rx, ¤t);
176 handle.exit(0);
177 });
178 Ok(())
179 })
180 .run(context)
181 .expect("error while running the fetch window");
182}
183
184fn drive(window: &WebviewWindow, job: &Job, rx: &Receiver<Event>, current: &Mutex<String>) {
185 let proxies: Vec<Option<&str>> = match job.settings.proxies.as_slice() {
186 [] => vec![None],
187 list => list.iter().map(|p| Some(p.as_str())).collect(),
188 };
189 for (n, paper) in job.papers.iter().enumerate() {
190 *current.lock().unwrap() = paper.file.clone();
191 let mut outcome = Outcome::Failed { key: paper.key.clone(), error: "no proxy gave a PDF".into() };
192 for (i, proxy) in proxies.iter().enumerate() {
193 let through = if proxies.len() > 1 { format!(" · proxy {}/{}", i + 1, proxies.len()) } else { String::new() };
194 let _ = window.set_title(&format!("yeda fetch {}/{} · {}{through} — sign in or click the PDF link if it stops", n + 1, job.papers.len(), paper.key));
195 match through_proxy(window, job, paper, *proxy, rx) {
196 Step::Done(o) => {
197 outcome = o;
198 break;
199 }
200 Step::NextProxy => {}
201 Step::Stop => {
202 for p in &job.papers[n..] {
203 report(&Outcome::Failed { key: p.key.clone(), error: "fetch window closed".into() });
204 }
205 return;
206 }
207 }
208 }
209 report(&outcome);
210 }
211}
212
213fn through_proxy(window: &WebviewWindow, job: &Job, paper: &Paper, proxy: Option<&str>, rx: &Receiver<Event>) -> Step {
217 if !blank_page(window, rx) {
219 return Step::Stop;
220 }
221 let target = job.downloads.join(&paper.file);
222 let part = job.downloads.join(format!("{}.part", paper.file));
223 let saved = |file: PathBuf| Step::Done(Outcome::Saved { key: paper.key.clone(), file });
224 for url in signed_in_urls(window, job, paper, proxy) {
226 download_natively(window, &url);
227 match wait_for_download(rx, &part, &target, paper) {
228 Some(step) => return step,
229 None => continue,
230 }
231 }
232 let doi = format!("https://doi.org/{}", paper.doi);
233 let url = proxy.map_or(doi.clone(), |p| fulltext::proxied(&doi, p));
234 if let Err(e) = url.parse().map_err(anyhow::Error::from).and_then(|u| Ok(window.navigate(u)?)) {
235 return Step::Done(Outcome::Failed { key: paper.key.clone(), error: format!("{e:#}") });
236 }
237 let script = report_script(proxy);
238 let mut loaded: Option<String> = None;
240 let mut tried: Vec<String> = Vec::new();
241 loop {
242 let event = match loaded {
243 Some(_) => rx.recv_timeout(Duration::from_secs(2)),
244 None => rx.recv().map_err(|_| RecvTimeoutError::Disconnected),
245 };
246 match event {
247 Ok(Event::Loaded(url)) => {
248 if url != "about:blank" {
249 report(&Outcome::Visited { key: paper.key.clone(), page: without_query(&url) });
250 }
251 let next = match (fulltext::sciencedirect_pdf(&url), fulltext::ieee(&url)) {
254 (Some(pdf), _) => Some(pdf),
255 (_, Some(fulltext::Ieee::Document { stamp })) => Some(stamp),
256 _ => None,
257 };
258 if let Some(next) = next.filter(|n| !tried.contains(n)) {
259 tried.push(next.clone());
260 let _ = next.parse().map(|u| window.navigate(u));
261 loaded = None;
263 continue;
264 }
265 if let Some(fulltext::Ieee::Stamp { pdf }) = fulltext::ieee(&url) {
267 download_natively(window, &pdf);
268 }
269 let _ = window.eval(&script);
270 loaded = Some(url);
271 }
272 Ok(Event::Title(title)) => {
273 let Some((page, pdf)) = title.strip_prefix(HTML).and_then(|t| t.split_once(' ')) else { continue };
274 if loaded.as_deref().is_some_and(|l| without_fragment(l) == without_fragment(page)) {
276 loaded = None;
277 }
278 if pdf.is_empty() || tried.iter().any(|t| t == pdf) {
279 continue;
280 }
281 tried.push(pdf.to_string());
282 download_in_page(window, pdf);
284 }
285 Err(RecvTimeoutError::Timeout) => {
287 let url = loaded.take().unwrap_or_default();
288 if !url.is_empty() && !tried.contains(&url) {
289 tried.push(url.clone());
290 report(&Outcome::Visited { key: paper.key.clone(), page: format!("WebKit download of {}", without_query(&url)) });
291 download_natively(window, &url);
292 }
293 }
294 Ok(Event::Downloaded(done)) if done == part => {
296 if let Some(file) = keep_pdf_or_report(&part, &target, paper) {
297 return saved(file);
298 }
299 }
300 Ok(Event::Downloaded(_)) => {}
301 Ok(Event::NewWindow(url)) => drop(url.parse().map(|u| window.navigate(u))),
302 Ok(Event::NextProxy) => return Step::NextProxy,
303 Ok(Event::Skip) => return skipped(paper),
304 Ok(Event::Stop) | Err(RecvTimeoutError::Disconnected) => return Step::Stop,
305 }
306 }
307}
308
309fn skipped(paper: &Paper) -> Step {
310 Step::Done(Outcome::Failed { key: paper.key.clone(), error: "skipped in the browser".into() })
311}
312
313fn signed_in_urls(window: &WebviewWindow, job: &Job, paper: &Paper, proxy: Option<&str>) -> Vec<String> {
315 let Some(host) = proxy.and_then(fulltext::proxy_host) else { return Vec::new() };
316 let signed_in = |url: &String| url.parse().ok().and_then(|u| window.cookies_for_url(u).ok()).is_some_and(|c| !c.is_empty());
317 fulltext::publisher_urls(&paper.doi, &job.settings).iter().filter_map(|u| fulltext::proxied_host(u, &host)).filter(signed_in).collect()
318}
319
320fn wait_for_download(rx: &Receiver<Event>, part: &Path, target: &Path, paper: &Paper) -> Option<Step> {
323 let deadline = Instant::now() + Duration::from_secs(30);
324 loop {
325 match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
326 Ok(Event::Downloaded(done)) if done == part => {
327 return keep_pdf_or_report(part, target, paper).map(|file| Step::Done(Outcome::Saved { key: paper.key.clone(), file }));
328 }
329 Ok(Event::Skip) => return Some(skipped(paper)),
330 Ok(Event::NextProxy) => return Some(Step::NextProxy),
331 Ok(Event::Stop) | Err(RecvTimeoutError::Disconnected) => return Some(Step::Stop),
332 Err(RecvTimeoutError::Timeout) => return None,
333 Ok(_) => {}
334 }
335 }
336}
337
338fn keep_pdf_or_report(part: &Path, target: &Path, paper: &Paper) -> Option<PathBuf> {
340 let bytes = std::fs::read(part).unwrap_or_default();
341 if !bytes.is_empty() && !fulltext::is_pdf(&bytes) {
342 let gist = fulltext::page_gist(&String::from_utf8_lossy(&bytes[..bytes.len().min(512 << 10)]));
343 report(&Outcome::Visited { key: paper.key.clone(), page: format!("downloaded a page, not a PDF: {gist}") });
344 }
345 keep_pdf(part, target)
346}
347
348fn blank_page(window: &WebviewWindow, rx: &Receiver<Event>) -> bool {
350 if window.navigate("about:blank".parse().unwrap()).is_err() {
351 return false;
352 }
353 loop {
354 match rx.recv_timeout(Duration::from_secs(10)) {
355 Ok(Event::Loaded(url)) if url == "about:blank" => return true,
356 Ok(Event::Stop) | Err(RecvTimeoutError::Disconnected) => return false,
357 Err(RecvTimeoutError::Timeout) => return true,
359 Ok(_) => {}
360 }
361 }
362}
363
364fn without_fragment(url: &str) -> &str {
365 url.split('#').next().unwrap_or_default()
366}
367
368fn without_query(url: &str) -> String {
370 url.split(['?', '#']).next().unwrap_or_default().to_string()
371}
372
373fn keep_pdf(part: &Path, target: &Path) -> Option<PathBuf> {
375 let bytes = std::fs::read(part).ok()?;
376 let tail = &bytes[bytes.len().saturating_sub(2048)..];
377 if !fulltext::is_pdf(&bytes) || !tail.windows(5).any(|w| w == b"%%EOF") {
378 let _ = std::fs::remove_file(part);
379 return None;
380 }
381 std::fs::rename(part, target).ok()?;
382 Some(target.to_path_buf())
383}
384
385#[cfg(test)]
386mod tests {
387 #[test]
388 fn keeps_only_whole_pdfs() {
389 let dir = std::env::temp_dir().join(format!("yeda-keep-pdf-{}", std::process::id()));
390 std::fs::create_dir_all(&dir).unwrap();
391 let (part, target) = (dir.join("x.pdf.part"), dir.join("x.pdf"));
392 std::fs::write(&part, b"%PDF-1.7\n1 0 obj").unwrap();
393 assert!(super::keep_pdf(&part, &target).is_none());
394 assert!(!part.exists());
395 std::fs::write(&part, b"%PDF-1.7\n1 0 obj\nendobj\n%%EOF\n").unwrap();
396 assert_eq!(super::keep_pdf(&part, &target), Some(target.clone()));
397 std::fs::remove_dir_all(&dir).unwrap();
398 }
399}