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}