Skip to main content

eoka_server/downloads/
mod.rs

1pub(crate) mod durable;
2mod files;
3mod target;
4mod worker;
5
6pub use target::resolve_click;
7
8use eoka::{Browser, Page};
9use eoka_protocol::{
10    ActionStatus, DownloadRecord, DownloadStartArgs, DownloadState, ErrorContext, ErrorDetail,
11    Response,
12};
13use files::{Destination, Publication};
14use std::collections::VecDeque;
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17use tokio::sync::{watch, Semaphore};
18use worker::Trigger;
19
20#[derive(Clone)]
21pub struct DownloadJob {
22    journal: Option<durable::Journal>,
23    owned: bool,
24    record: watch::Sender<DownloadRecord>,
25    cancel: watch::Sender<bool>,
26    publication: Arc<Mutex<Publication>>,
27}
28
29impl DownloadJob {
30    pub fn snapshot(&self) -> DownloadRecord {
31        self.record.borrow().clone()
32    }
33
34    async fn update(&self, apply: impl FnOnce(&mut DownloadRecord)) -> Result<(), ErrorDetail> {
35        let mut record = self.snapshot();
36        apply(&mut record);
37        record.updated_at_ms = now();
38        if let Some(journal) = &self.journal {
39            journal.save(record.clone()).await.map_err(|error| {
40                failure(
41                    "download_store_failed",
42                    format!("Could not persist download state: {error}"),
43                )
44            })?;
45        }
46        self.record.send_replace(record);
47        Ok(())
48    }
49
50    pub fn cancel(&self) -> DownloadRecord {
51        if !self.owned {
52            return self.snapshot();
53        }
54        let mut gate = self.publication.lock().unwrap_or_else(|e| e.into_inner());
55        if *gate == Publication::Open && !self.record.borrow().state.is_terminal() {
56            *gate = Publication::Canceled;
57            self.cancel.send_replace(true);
58        }
59        self.snapshot()
60    }
61
62    pub async fn wait(&self, timeout: Duration) -> Response {
63        let mut changes = self.record.subscribe();
64        let result = tokio::time::timeout(timeout, async {
65            loop {
66                let record = changes.borrow_and_update().clone();
67                if record.state.is_terminal() {
68                    return record;
69                }
70                if changes.changed().await.is_err() {
71                    return self.snapshot();
72                }
73            }
74        })
75        .await;
76        match result {
77            Ok(record) if record.state == DownloadState::Completed => record_response(record),
78            Ok(record) => {
79                let error = record
80                    .error
81                    .clone()
82                    .unwrap_or_else(|| failure("download_canceled", "Download canceled"));
83                error_with_record(record, error)
84            }
85            Err(_) => {
86                let record = self.snapshot();
87                let error = failure(
88                    "download_wait_timeout",
89                    "Wait timed out; the job continues. Inspect its status or wait again.",
90                )
91                .hint(format!(
92                    "Use download status {} or download wait {}; do not start another transfer.",
93                    record.id, record.id
94                ));
95                error_with_record(record, error)
96            }
97        }
98    }
99}
100
101struct SetupGuard {
102    journal: durable::Journal,
103    pending: Option<DownloadRecord>,
104}
105impl Drop for SetupGuard {
106    fn drop(&mut self) {
107        let Some(record) = self.pending.take() else {
108            return;
109        };
110        let journal = self.journal.clone();
111        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
112            runtime.spawn(async move {
113                fail_setup(
114                    &journal,
115                    record,
116                    failure(
117                        "download_setup_canceled",
118                        "Download setup was canceled before trigger dispatch",
119                    ),
120                )
121                .await;
122            });
123        }
124    }
125}
126async fn fail_setup(journal: &durable::Journal, mut record: DownloadRecord, error: ErrorDetail) {
127    record.state = DownloadState::Failed;
128    record.updated_at_ms = now();
129    record.error = Some(error.clone());
130    let _ = journal.save(record).await;
131    let _ = journal
132        .store
133        .update(journal.id.clone(), move |op| {
134            op.state = eoka_protocol::OperationState::Finished;
135            op.finished_at_ms = Some(now());
136            op.error_code = Some(error.code);
137            op.outcome = eoka::ActionOutcome::NotDispatched;
138        })
139        .await;
140}
141
142struct Inner {
143    operations: crate::operations::OperationManager,
144    jobs: Mutex<VecDeque<DownloadJob>>,
145    slot: Arc<Semaphore>,
146    waiters: Semaphore,
147}
148
149#[derive(Clone)]
150pub struct DownloadManager {
151    inner: Arc<Inner>,
152}
153
154impl Default for DownloadManager {
155    fn default() -> Self {
156        Self {
157            inner: Arc::new(Inner {
158                operations: Default::default(),
159                jobs: Mutex::new(VecDeque::new()),
160                slot: Arc::new(Semaphore::new(1)),
161                waiters: Semaphore::new(32),
162            }),
163        }
164    }
165}
166
167impl DownloadManager {
168    pub async fn restore(&self) -> Result<(), ErrorDetail> {
169        let store = self
170            .inner
171            .operations
172            .store()
173            .await
174            .map_err(|_| {
175                failure(
176                    "download_store_failed",
177                    "Could not open durable download history",
178                )
179            })?
180            .clone();
181        store
182            .recover()
183            .await
184            .map_err(|e| failure("download_store_failed", e.to_string()))?;
185        let records = store
186            .run(|connection| {
187                let rows = connection
188                    .prepare("SELECT record FROM download_records ORDER BY id")?
189                    .query_map([], |r| r.get::<_, String>(0))?
190                    .collect::<rusqlite::Result<Vec<_>>>()?;
191                rows.into_iter()
192                    .map(|row| Ok(serde_json::from_str::<DownloadRecord>(&row)?))
193                    .collect::<anyhow::Result<Vec<_>>>()
194            })
195            .await
196            .map_err(|_| {
197                failure(
198                    "download_store_failed",
199                    "Could not restore download history",
200                )
201            })?;
202        let mut jobs = self.inner.jobs.lock().unwrap_or_else(|e| e.into_inner());
203        for record in records {
204            if let Some(job) = jobs.iter().find(|j| j.snapshot().id == record.id) {
205                if !job.owned {
206                    job.record.send_replace(record);
207                }
208            } else {
209                jobs.push_back(DownloadJob {
210                    journal: None,
211                    owned: false,
212                    record: watch::channel(record).0,
213                    cancel: watch::channel(false).0,
214                    publication: Arc::new(Mutex::new(Publication::Open)),
215                });
216            }
217        }
218        Ok(())
219    }
220
221    pub async fn existing(
222        &self,
223        args: &DownloadStartArgs,
224    ) -> Result<Option<DownloadRecord>, ErrorDetail> {
225        let Some(id) = &args.operation_id else {
226            return Ok(None);
227        };
228        let store = self
229            .inner
230            .operations
231            .store()
232            .await
233            .map_err(|_| failure("download_store_failed", "Could not open durable history"))?;
234        let existing = store
235            .lookup(id.clone(), canonical_request(args)?)
236            .await
237            .map_err(|e| failure("download_admission_failed", e.to_string()))?;
238        if existing.is_none() {
239            return Ok(None);
240        };
241        self.restore().await?;
242        self.get(id).map(|job| Some(job.snapshot()))
243    }
244
245    pub fn is_active(&self) -> bool {
246        self.inner.slot.available_permits() == 0
247    }
248
249    pub fn list(&self) -> Vec<DownloadRecord> {
250        self.inner
251            .jobs
252            .lock()
253            .unwrap_or_else(|e| e.into_inner())
254            .iter()
255            .rev()
256            .map(DownloadJob::snapshot)
257            .collect()
258    }
259
260    pub fn get(&self, id: &str) -> Result<DownloadJob, ErrorDetail> {
261        self.inner
262            .jobs
263            .lock()
264            .unwrap_or_else(|e| e.into_inner())
265            .iter()
266            .find(|job| job.record.borrow().id == id)
267            .cloned()
268            .ok_or_else(|| {
269                failure(
270                    "download_not_found",
271                    "Download job not found in this daemon",
272                )
273            })
274    }
275
276    pub async fn wait(&self, id: &str, timeout_ms: u64) -> Response {
277        let Ok(_permit) = self.inner.waiters.try_acquire() else {
278            return Response::err_detail(failure(
279                "download_wait_busy",
280                "Too many concurrent download waiters",
281            ));
282        };
283        if timeout_ms == 0 || timeout_ms > 86_400_000 {
284            return Response::err_detail(failure(
285                "invalid_input",
286                "timeout_ms must be between 1 and 86400000",
287            ));
288        }
289        if let Err(error) = self.restore().await {
290            return Response::err_detail(error);
291        }
292        let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms);
293        loop {
294            if let Err(error) = self.restore().await {
295                return Response::err_detail(error);
296            }
297            let job = match self.get(id) {
298                Ok(job) => job,
299                Err(error) => return Response::err_detail(error),
300            };
301            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
302            let response = job
303                .wait(if job.owned {
304                    remaining
305                } else {
306                    remaining.min(Duration::from_millis(100))
307                })
308                .await;
309            if job.owned
310                || job.snapshot().state.is_terminal()
311                || tokio::time::Instant::now() >= deadline
312            {
313                return response;
314            }
315        }
316    }
317
318    pub fn cancel(&self, id: &str) -> Result<DownloadRecord, ErrorDetail> {
319        let job = self.get(id)?;
320        if !job.owned && !job.snapshot().state.is_terminal() {
321            return Err(failure(
322                "download_owned_elsewhere",
323                "Request cancellation from the owning runtime",
324            ));
325        }
326        Ok(job.cancel())
327    }
328
329    pub async fn stop(&self) {
330        let jobs: Vec<_> = self
331            .inner
332            .jobs
333            .lock()
334            .unwrap_or_else(|e| e.into_inner())
335            .iter()
336            .filter(|job| job.owned)
337            .cloned()
338            .collect();
339        for job in &jobs {
340            job.cancel();
341        }
342        for job in jobs {
343            if !job.snapshot().state.is_terminal() {
344                job.wait(Duration::from_secs(12)).await;
345            }
346        }
347    }
348
349    pub async fn start(
350        &self,
351        browser: &Browser,
352        source: &Page,
353        args: DownloadStartArgs,
354        selector: Option<eoka::LocatorSpec>,
355    ) -> Result<DownloadRecord, ErrorDetail> {
356        if let Some(record) = self.existing(&args).await? {
357            return Ok(record);
358        }
359        args.validate()
360            .map_err(|message| failure("invalid_input", message))?;
361        if !browser.owns_process() {
362            return Err(failure(
363                "download_unsupported",
364                "Only Eoka-owned Chrome supports downloads",
365            ));
366        }
367        let trigger = if let Some(url) = &args.url {
368            let parsed = url::Url::parse(url)
369                .map_err(|_| failure("invalid_input", "Invalid download URL"))?;
370            if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
371                return Err(failure(
372                    "invalid_input",
373                    "Attachment URLs must use HTTP or HTTPS",
374                ));
375            }
376            Trigger::Navigate {
377                url: url.clone(),
378                referrer: source.url().await.ok(),
379            }
380        } else {
381            Trigger::Click {
382                selector: selector
383                    .ok_or_else(|| failure("invalid_input", "Click target was not resolved"))?,
384            }
385        };
386        let permit = self.inner.slot.clone().try_acquire_owned().map_err(|_| {
387            failure(
388                "download_busy",
389                "One download is already active in this session",
390            )
391        })?;
392        let id = args
393            .operation_id
394            .clone()
395            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
396        uuid::Uuid::parse_str(&id)
397            .map_err(|_| failure("invalid_input", "operation_id must be a UUID"))?;
398        let store = self
399            .inner
400            .operations
401            .store()
402            .await
403            .map_err(|_| failure("download_store_failed", "Could not open durable store"))?
404            .clone();
405        let operation = eoka_protocol::OperationRecord {
406            id: id.clone(),
407            runtime_id: store.runtime.clone(),
408            target_id: source.target_id().into(),
409            action: "download".into(),
410            state: eoka_protocol::OperationState::Running,
411            outcome: eoka::ActionOutcome::NotDispatched,
412            dispatch_intent: false,
413            preparation_intent: false,
414            started_at_ms: now(),
415            finished_at_ms: None,
416            receipt: None,
417            error_code: None,
418        };
419        let (_, created) = store
420            .admit(operation, canonical_request(&args)?)
421            .await
422            .map_err(|error| failure("download_admission_failed", error.to_string()))?;
423        if !created {
424            self.restore().await?;
425            return self.get(&id).map(|job| job.snapshot());
426        }
427        let journal = durable::Journal {
428            store,
429            id: id.clone(),
430        };
431        let pending = DownloadRecord {
432            native_guid: None,
433            native_complete: false,
434            sha256: None,
435            id: id.clone(),
436            tab_id: source.target_id().into(),
437            state: DownloadState::Pending,
438            output: args.output.clone(),
439            published_path: None,
440            staging_path: None,
441            received_bytes: 0,
442            total_bytes: None,
443            suggested_filename: None,
444            source_url: args.url.as_deref().map(redact_url),
445            created_at_ms: now(),
446            updated_at_ms: now(),
447            error: None,
448        };
449        let mut setup = SetupGuard {
450            journal: journal.clone(),
451            pending: Some(pending.clone()),
452        };
453        journal
454            .save(pending.clone())
455            .await
456            .map_err(|e| failure("download_store_failed", e.to_string()))?;
457        let result: Result<DownloadRecord, ErrorDetail> = async {
458            let output = args.output.clone();
459            let prepare_id = id.clone();
460            let expected = args.expect_bytes;
461            let destination = Arc::new(
462                tokio::task::spawn_blocking(move || {
463                    Destination::prepare(&output, &prepare_id, expected)
464                })
465                .await
466                .map_err(|_| failure("download_io_error", "Destination preparation failed"))??,
467            );
468            let auxiliary = args.url.is_some();
469            let page = if auxiliary {
470                match browser.new_blank_page().await {
471                    Ok(page) => page,
472                    Err(error) => {
473                        destination.retained_path();
474                        return Err(failure("download_setup_failed", error.to_string()));
475                    }
476                }
477            } else {
478                source.clone()
479            };
480            let mut auxiliary = auxiliary.then(|| worker::AuxiliaryPage::new(page.clone()));
481            let native = match browser.watch_downloads(&destination.staging).await {
482                Ok(native) => native,
483                Err(error) => {
484                    if let Some(auxiliary) = auxiliary.as_mut() {
485                        let _ = auxiliary.close().await;
486                    }
487                    destination.retained_path();
488                    return Err(failure("download_setup_failed", error.to_string()));
489                }
490            };
491            let record = DownloadRecord {
492                native_guid: None,
493                native_complete: false,
494                sha256: None,
495                id,
496                tab_id: source.target_id().into(),
497                state: DownloadState::Pending,
498                output: destination.output.to_string_lossy().into_owned(),
499                published_path: None,
500                staging_path: Some(destination.staging.to_string_lossy().into_owned()),
501                received_bytes: 0,
502                total_bytes: None,
503                suggested_filename: None,
504                source_url: args.url.as_deref().map(redact_url),
505                created_at_ms: now(),
506                updated_at_ms: now(),
507                error: None,
508            };
509            journal.save(record.clone()).await.map_err(|_| {
510                failure(
511                    "download_store_failed",
512                    "Could not persist download admission",
513                )
514            })?;
515            let job = DownloadJob {
516                journal: Some(journal.clone()),
517                owned: true,
518                record: watch::channel(record).0,
519                cancel: watch::channel(false).0,
520                publication: Arc::new(Mutex::new(Publication::Open)),
521            };
522            {
523                let mut jobs = self.inner.jobs.lock().unwrap_or_else(|e| e.into_inner());
524                jobs.push_back(job.clone());
525            }
526            let (dispatched, _dispatched_rx) = tokio::sync::oneshot::channel();
527            let context = worker::Transfer {
528                job: job.clone(),
529                native,
530                destination,
531                page,
532                auxiliary,
533                trigger,
534                expected_bytes: args.expect_bytes,
535                start_timeout: Duration::from_millis(args.start_timeout_ms),
536                permit,
537                dispatched: Some(dispatched),
538                action_status: ActionStatus::NotAttempted,
539            };
540            tokio::spawn(context.run());
541            Ok(job.snapshot())
542        }
543        .await;
544        if let Err(error) = &result {
545            fail_setup(&journal, pending, error.clone()).await;
546        }
547        setup.pending.take();
548        result
549    }
550}
551
552impl Drop for Inner {
553    fn drop(&mut self) {
554        for job in self.jobs.get_mut().unwrap_or_else(|e| e.into_inner()) {
555            job.cancel();
556        }
557    }
558}
559
560pub fn failure(code: &str, message: impl Into<String>) -> ErrorDetail {
561    ErrorDetail::new(code, message).retryable(false)
562}
563
564pub fn rejected(mut error: ErrorDetail) -> Response {
565    error
566        .context
567        .get_or_insert_with(Default::default)
568        .action_status
569        .get_or_insert(ActionStatus::NotAttempted);
570    error.hint.get_or_insert_with(|| "No download action was attempted. Inspect the page before choosing a target; do not pre-click or restart the browser.".into());
571    Response::err_detail(error)
572}
573
574async fn diagnose(
575    error: ErrorDetail,
576    page: &Page,
577    action_status: ActionStatus,
578    id: Option<String>,
579) -> ErrorDetail {
580    let mut context = crate::page_context::capture(page).await;
581    if let Some(context) = context.as_mut() {
582        context.url = redact_url(&context.url);
583    }
584    error.context(ErrorContext {
585        page: context,
586        download_id: id,
587        action_status: Some(action_status),
588    })
589}
590
591pub fn record_response(record: DownloadRecord) -> Response {
592    match serde_json::to_value(eoka_protocol::DownloadResult::from(record)) {
593        Ok(record) => Response::ok(record),
594        Err(error) => Response::err(error.to_string()),
595    }
596}
597
598pub fn start_response(result: Result<DownloadRecord, ErrorDetail>) -> Response {
599    match result {
600        Ok(record)
601            if matches!(
602                record.state,
603                DownloadState::Failed | DownloadState::Canceled
604            ) =>
605        {
606            let error = record
607                .error
608                .clone()
609                .unwrap_or_else(|| failure("download_failed", "Download failed"));
610            error_with_record(record, error)
611        }
612        Ok(record) => record_response(record),
613        Err(error) => rejected(error),
614    }
615}
616
617pub fn error_with_record(record: DownloadRecord, mut error: ErrorDetail) -> Response {
618    let context = error.context.get_or_insert_with(Default::default);
619    context.download_id = Some(record.id.clone());
620    context
621        .action_status
622        .get_or_insert(ActionStatus::MayHaveExecuted);
623    match serde_json::to_value(record) {
624        Ok(record) => {
625            let mut response = Response::err_detail(error);
626            response.data = Some(record);
627            response
628        }
629        Err(error) => Response::err(error.to_string()),
630    }
631}
632
633fn now() -> u64 {
634    SystemTime::now()
635        .duration_since(UNIX_EPOCH)
636        .unwrap_or_default()
637        .as_millis()
638        .min(u64::MAX as u128) as u64
639}
640
641fn redact_url(value: &str) -> String {
642    url::Url::parse(value)
643        .map(|mut url| {
644            let _ = url.set_username("");
645            let _ = url.set_password(None);
646            url.set_query(None);
647            url.set_fragment(None);
648            url.to_string().chars().take(4096).collect()
649        })
650        .unwrap_or_else(|_| "[unavailable]".into())
651}
652
653fn canonical_request(args: &DownloadStartArgs) -> Result<Vec<u8>, ErrorDetail> {
654    let mut args = args.clone();
655    args.operation_id = None;
656    serde_json::to_vec(&args)
657        .map_err(|_| failure("invalid_input", "Could not encode download request"))
658}