diff --git a/Cargo.lock b/Cargo.lock index 20a67e0..8675925 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,7 +614,7 @@ dependencies = [ "tokio", "tokio-rustls", "tungstenite", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -2256,6 +2256,8 @@ dependencies = [ "sha2", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", + "ureq", + "url", ] [[package]] @@ -4009,7 +4011,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -8910,7 +8912,7 @@ dependencies = [ "tungstenite", "url", "uuid", - "webpki-roots", + "webpki-roots 1.0.7", "webrender_api", ] @@ -11117,6 +11119,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -11662,6 +11680,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" diff --git a/Cargo.toml b/Cargo.toml index d186b66..f2216b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" thiserror = "2.0.12" toml = "1.1.2" +ureq = "2.12.1" url = "2.5.4" uuid = { version = "1.12.1", features = ["v7"] } diff --git a/crates/ely_app/Cargo.toml b/crates/ely_app/Cargo.toml index d4cb6a8..c654ace 100644 --- a/crates/ely_app/Cargo.toml +++ b/crates/ely_app/Cargo.toml @@ -22,6 +22,8 @@ serde = { workspace = true, features = ["derive"] } serde_json.workspace = true sha2.workspace = true thiserror.workspace = true +ureq.workspace = true +url.workspace = true [build-dependencies] toml.workspace = true diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index 0d92881..60b4ae8 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -26,6 +26,7 @@ actions!( ely_app, [ CloseCurrentTab, + DownloadCurrentPage, FocusAddressBar, FocusCommandMode, OpenDownloads, @@ -81,6 +82,9 @@ fn main() { MenuItem::action("Command Mode", FocusCommandMode), MenuItem::action("Toggle Sidebar", ToggleSidebar), MenuItem::separator(), + MenuItem::action("Download Current Page", DownloadCurrentPage), + MenuItem::action("Open Downloads", OpenDownloads), + MenuItem::separator(), MenuItem::action("Zoom In", ZoomIn), MenuItem::action("Zoom Out", ZoomOut), MenuItem::action("Reset Zoom", ResetZoom), @@ -92,7 +96,6 @@ fn main() { MenuItem::action("Next Space", SelectNextSpace), MenuItem::action("Previous Space", SelectPreviousSpace), MenuItem::separator(), - MenuItem::action("Open Downloads", OpenDownloads), MenuItem::action("Open History", OpenHistory), MenuItem::action("Open Task Manager", OpenTaskManager), MenuItem::action("Open Settings", OpenSettings), diff --git a/crates/ely_app/src/services/http_downloads.rs b/crates/ely_app/src/services/http_downloads.rs new file mode 100644 index 0000000..c0cb01b --- /dev/null +++ b/crates/ely_app/src/services/http_downloads.rs @@ -0,0 +1,370 @@ +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + time::Duration, +}; + +use ely_domain::UrlText; +use thiserror::Error; +use url::Url; + +const DOWNLOAD_BUFFER_BYTES: usize = 64 * 1024; +const DOWNLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(20); +const DOWNLOAD_READ_TIMEOUT: Duration = Duration::from_secs(120); +const USER_AGENT: &str = concat!("ELY Browser/", env!("CARGO_PKG_VERSION")); + +#[derive(Clone, Debug)] +pub struct HttpDownloadPlan { + url: UrlText, + target_path: PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpDownloadResult { + target_path: PathBuf, + received_bytes: u64, + total_bytes: Option, +} + +#[derive(Debug, Error)] +pub enum HttpDownloadError { + #[error("download URL is invalid: {url}")] + InvalidUrl { url: String }, + + #[error("unsupported download URL scheme: {scheme}")] + UnsupportedScheme { scheme: String }, + + #[error("download target has no parent directory: {path}")] + MissingTargetDirectory { path: PathBuf }, + + #[error("failed to create download directory: {path}: {source}")] + DirectoryUnavailable { + path: PathBuf, + #[source] + source: io::Error, + }, + + #[error("failed to create download file: {path}: {source}")] + CreateFile { + path: PathBuf, + #[source] + source: io::Error, + }, + + #[error("download returned HTTP {status} for {url}")] + HttpStatus { url: String, status: u16 }, + + #[error("download request failed for {url}: {source}")] + Request { + url: String, + #[source] + source: Box, + }, + + #[error("failed to read download stream for {url}: {source}")] + Read { + url: String, + #[source] + source: io::Error, + }, + + #[error("failed to write download file: {path}: {source}")] + Write { + path: PathBuf, + #[source] + source: io::Error, + }, + + #[error("failed to finalize download file: {from} -> {to}: {source}")] + FinalizeFile { + from: PathBuf, + to: PathBuf, + #[source] + source: io::Error, + }, + + #[error("download ended at {received_bytes} bytes, expected {total_bytes} bytes for {url}")] + ContentLengthMismatch { url: String, received_bytes: u64, total_bytes: u64 }, +} + +impl HttpDownloadPlan { + #[must_use] + pub fn new(url: UrlText, target_path: PathBuf) -> Self { + Self { url, target_path } + } + + #[must_use] + pub fn target_path(&self) -> &Path { + &self.target_path + } +} + +impl HttpDownloadResult { + #[must_use] + pub fn received_bytes(&self) -> u64 { + self.received_bytes + } +} + +pub fn download_to_file(plan: HttpDownloadPlan) -> Result { + validate_download_url(&plan.url)?; + prepare_target_directory(plan.target_path())?; + + let response = call_download_url(&plan.url)?; + let total_bytes = content_length(&response); + let mut reader = response.into_reader(); + let (temporary_path, mut file) = create_temporary_file(plan.target_path())?; + let stream_result = stream_response_body(&plan.url, &temporary_path, &mut reader, &mut file); + let received_bytes = match stream_result { + Ok(received_bytes) => received_bytes, + Err(error) => { + _ = fs::remove_file(&temporary_path); + return Err(error); + } + }; + + if let Err(error) = validate_content_length(plan.url.as_str(), received_bytes, total_bytes) { + _ = fs::remove_file(&temporary_path); + return Err(error); + } + fs::rename(&temporary_path, plan.target_path()).map_err(|source| { + HttpDownloadError::FinalizeFile { + from: temporary_path, + to: plan.target_path.clone(), + source, + } + })?; + + Ok(HttpDownloadResult { target_path: plan.target_path, received_bytes, total_bytes }) +} + +fn stream_response_body( + url: &UrlText, + target_path: &Path, + reader: &mut dyn Read, + file: &mut File, +) -> Result { + let mut buffer = [0; DOWNLOAD_BUFFER_BYTES]; + let mut received_bytes = 0; + + loop { + let bytes_read = reader + .read(&mut buffer) + .map_err(|source| HttpDownloadError::Read { url: url.as_str().to_string(), source })?; + if bytes_read == 0 { + break; + } + file.write_all(&buffer[..bytes_read]).map_err(|source| HttpDownloadError::Write { + path: target_path.to_path_buf(), + source, + })?; + received_bytes += bytes_read as u64; + } + + file.flush() + .map_err(|source| HttpDownloadError::Write { path: target_path.to_path_buf(), source })?; + Ok(received_bytes) +} + +fn call_download_url(url: &UrlText) -> Result { + let agent = ureq::AgentBuilder::new() + .timeout_connect(DOWNLOAD_CONNECT_TIMEOUT) + .timeout_read(DOWNLOAD_READ_TIMEOUT) + .build(); + + match agent + .get(url.as_str()) + .set("User-Agent", USER_AGENT) + .set("Accept-Encoding", "identity") + .call() + { + Ok(response) => Ok(response), + Err(ureq::Error::Status(status, _)) => { + Err(HttpDownloadError::HttpStatus { url: url.as_str().to_string(), status }) + } + Err(source) => Err(HttpDownloadError::Request { + url: url.as_str().to_string(), + source: Box::new(source), + }), + } +} + +fn validate_download_url(url: &UrlText) -> Result<(), HttpDownloadError> { + let parsed = Url::parse(url.as_str()) + .map_err(|_| HttpDownloadError::InvalidUrl { url: url.as_str().to_string() })?; + match parsed.scheme() { + "http" | "https" => Ok(()), + scheme => Err(HttpDownloadError::UnsupportedScheme { scheme: scheme.to_string() }), + } +} + +fn prepare_target_directory(target_path: &Path) -> Result<(), HttpDownloadError> { + let Some(directory) = target_path.parent() else { + return Err(HttpDownloadError::MissingTargetDirectory { path: target_path.to_path_buf() }); + }; + + fs::create_dir_all(directory).map_err(|source| HttpDownloadError::DirectoryUnavailable { + path: directory.to_path_buf(), + source, + }) +} + +fn create_temporary_file(target_path: &Path) -> Result<(PathBuf, File), HttpDownloadError> { + let file_name = + target_path.file_name().map(|value| value.to_string_lossy()).unwrap_or_default(); + let prefix = if file_name.is_empty() { "download".into() } else { file_name }; + + for attempt in 0..1_000 { + let temporary_name = if attempt == 0 { + format!(".{prefix}.elydownload") + } else { + format!(".{prefix}.elydownload.{attempt}") + }; + let temporary_path = target_path.with_file_name(temporary_name); + match OpenOptions::new().create_new(true).write(true).open(&temporary_path) { + Ok(file) => return Ok((temporary_path, file)), + Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {} + Err(source) => { + return Err(HttpDownloadError::CreateFile { path: temporary_path, source }); + } + } + } + + Err(HttpDownloadError::CreateFile { + path: target_path.with_file_name(format!(".{prefix}.elydownload")), + source: io::Error::new(io::ErrorKind::AlreadyExists, "temporary download path exhausted"), + }) +} + +fn content_length(response: &ureq::Response) -> Option { + response.header("Content-Length").and_then(|value| value.parse().ok()) +} + +fn validate_content_length( + url: &str, + received_bytes: u64, + total_bytes: Option, +) -> Result<(), HttpDownloadError> { + if let Some(total_bytes) = total_bytes + && received_bytes != total_bytes + { + return Err(HttpDownloadError::ContentLengthMismatch { + url: url.to_string(), + received_bytes, + total_bytes, + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + error::Error, + fs, + io::{Read, Write}, + net::TcpListener, + path::PathBuf, + thread::{self, JoinHandle}, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + + use ely_domain::UrlText; + + use super::{HttpDownloadError, HttpDownloadPlan, download_to_file}; + + type TestServerHandle = JoinHandle>; + type SpawnedHttpServer = (String, TestServerHandle); + + #[test] + fn downloads_local_http_response_to_file() -> Result<(), Box> { + let body = b"ELY download body".to_vec(); + let (url, server) = spawn_http_server(body.clone(), 200)?; + let target_path = temp_file_path("body")?; + + let result = + download_to_file(HttpDownloadPlan::new(UrlText::parse(url)?, target_path.clone()))?; + + assert_eq!(result.target_path, target_path); + assert_eq!(result.received_bytes(), body.len() as u64); + assert_eq!(result.total_bytes, Some(body.len() as u64)); + assert_eq!(fs::read(&target_path)?, body); + fs::remove_file(target_path)?; + join_server(server)?; + Ok(()) + } + + #[test] + fn rejects_internal_scheme_before_writing_file() -> Result<(), Box> { + let target_path = temp_file_path("internal")?; + + let error = match download_to_file(HttpDownloadPlan::new( + UrlText::parse("ely://downloads")?, + target_path.clone(), + )) { + Ok(_) => return Err("internal scheme download succeeded".into()), + Err(error) => error, + }; + + assert!( + matches!(error, HttpDownloadError::UnsupportedScheme { scheme } if scheme == "ely") + ); + assert!(!target_path.exists()); + Ok(()) + } + + #[test] + fn reports_http_status_errors() -> Result<(), Box> { + let (url, server) = spawn_http_server(Vec::new(), 404)?; + let target_path = temp_file_path("status")?; + + let error = match download_to_file(HttpDownloadPlan::new( + UrlText::parse(url.clone())?, + target_path.clone(), + )) { + Ok(_) => return Err("HTTP error download succeeded".into()), + Err(error) => error, + }; + + assert!(matches!(error, HttpDownloadError::HttpStatus { status: 404, .. })); + assert!(!target_path.exists()); + join_server(server)?; + Ok(()) + } + + fn spawn_http_server(body: Vec, status: u16) -> Result> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + let handle = thread::spawn(move || -> Result<(), std::io::Error> { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + let mut request_buffer = [0; 1024]; + _ = stream.read(&mut request_buffer)?; + + let status_text = if status == 200 { "OK" } else { "Not Found" }; + let response = format!( + "HTTP/1.1 {status} {status_text}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes())?; + stream.write_all(&body)?; + stream.flush() + }); + + Ok((format!("http://{address}/download.bin"), handle)) + } + + fn join_server(handle: TestServerHandle) -> Result<(), Box> { + match handle.join() { + Ok(result) => result.map_err(Into::into), + Err(_) => Err("HTTP server thread panicked".into()), + } + } + + fn temp_file_path(name: &str) -> Result> { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + Ok(std::env::temp_dir().join(format!("ely-http-download-{name}-{nanos}.bin"))) + } +} diff --git a/crates/ely_app/src/services/mod.rs b/crates/ely_app/src/services/mod.rs index 1977cd5..3fa3b39 100644 --- a/crates/ely_app/src/services/mod.rs +++ b/crates/ely_app/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod download_checksums; pub mod download_files; +pub mod http_downloads; pub mod plugin_package_store; pub mod plugin_packages; pub mod plugin_signatures; diff --git a/crates/ely_app/src/shell/command_actions.rs b/crates/ely_app/src/shell/command_actions.rs index 7319d6f..e38bab0 100644 --- a/crates/ely_app/src/shell/command_actions.rs +++ b/crates/ely_app/src/shell/command_actions.rs @@ -42,6 +42,9 @@ impl ElyShell { if install_plugin_from_file_command(command) { self.choose_plugin_package(window, cx); } + if download_current_page_command(command) { + self.download_active_tab(window, cx); + } match space_file_command(command) { Some(SpaceFileCommand::ExportActiveSpace) => self.export_active_space(window, cx), @@ -83,6 +86,18 @@ fn install_plugin_from_file_command(command: &str) -> bool { ) } +fn download_current_page_command(command: &str) -> bool { + matches!( + command.trim().to_ascii_lowercase().as_str(), + "download-current-page" + | "download current page" + | "save-page" + | "save page" + | "save-current-page" + | "save current page" + ) +} + fn space_file_command(command: &str) -> Option { match command.trim().to_ascii_lowercase().as_str() { "export-space" | "export space" | "export-active-space" | "export active space" => { @@ -134,8 +149,8 @@ fn local_data_file_command(command: &str) -> Option { mod tests { use super::{ BookmarkFileCommand, LocalDataFileCommand, ShortcutFileCommand, SpaceFileCommand, - bookmark_file_command, install_plugin_from_file_command, local_data_file_command, - shortcut_file_command, space_file_command, + bookmark_file_command, download_current_page_command, install_plugin_from_file_command, + local_data_file_command, shortcut_file_command, space_file_command, }; #[test] @@ -151,6 +166,13 @@ mod tests { assert!(!install_plugin_from_file_command("open plugins")); } + #[test] + fn download_current_page_command_matches_save_aliases() { + assert!(download_current_page_command("download-current-page")); + assert!(download_current_page_command("save current page")); + assert!(!download_current_page_command("open downloads")); + } + #[test] fn space_file_command_matches_export_and_import_aliases() { assert_eq!(space_file_command("export-space"), Some(SpaceFileCommand::ExportActiveSpace)); diff --git a/crates/ely_app/src/shell/download_targets.rs b/crates/ely_app/src/shell/download_targets.rs new file mode 100644 index 0000000..2d906e2 --- /dev/null +++ b/crates/ely_app/src/shell/download_targets.rs @@ -0,0 +1,156 @@ +use std::path::{Path, PathBuf}; + +use directories::UserDirs; +use ely_browser_core::BrowserCore; +use ely_domain::{DownloadDestination, UrlText}; +use url::Url; + +pub(super) enum ActiveTabDownloadTarget { + Prompt { url: UrlText, directory: PathBuf, file_name: String }, + Ready { url: UrlText, target_path: PathBuf }, +} + +pub(super) fn active_tab_download_target( + core: &BrowserCore, +) -> Result { + let tab = core.active_tab().map_err(|error| error.to_string())?; + let parsed = Url::parse(tab.url().as_str()).map_err(|error| error.to_string())?; + match parsed.scheme() { + "http" | "https" => {} + _ => return Err("Only HTTP and HTTPS pages can be downloaded.".to_string()), + } + + let snapshot = core.snapshot().map_err(|error| error.to_string())?; + let file_name = suggested_download_file_name(tab.url(), tab.title()); + match snapshot.active_download_policy.destination() { + DownloadDestination::AskEveryTime => Ok(ActiveTabDownloadTarget::Prompt { + url: tab.url().clone(), + directory: default_download_directory()?, + file_name, + }), + DownloadDestination::FixedDirectory(directory) => Ok(ActiveTabDownloadTarget::Ready { + url: tab.url().clone(), + target_path: unique_download_target_path(directory, &file_name), + }), + } +} + +fn default_download_directory() -> Result { + UserDirs::new() + .and_then(|dirs| dirs.download_dir().map(Path::to_path_buf)) + .ok_or_else(|| "Downloads directory is unavailable.".to_string()) +} + +fn suggested_download_file_name(url: &UrlText, title: &str) -> String { + let parsed = Url::parse(url.as_str()).ok(); + let url_file_name = parsed + .as_ref() + .filter(|url| !url.path().ends_with('/')) + .and_then(|url| url.path_segments()) + .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty())) + .map(str::to_string); + let raw_file_name = url_file_name + .or_else(|| page_title_file_name(title)) + .or_else(|| { + parsed.as_ref().and_then(|url| url.host_str().map(|host| format!("{host}.html"))) + }) + .unwrap_or_else(|| "download.html".to_string()); + + sanitize_download_file_name(&raw_file_name) +} + +fn page_title_file_name(title: &str) -> Option { + let title = title.trim(); + (!title.is_empty()).then(|| format!("{title}.html")) +} + +fn sanitize_download_file_name(file_name: &str) -> String { + let cleaned = file_name + .trim() + .chars() + .filter_map(|ch| match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => Some('-'), + ch if ch.is_control() => None, + ch => Some(ch), + }) + .take(160) + .collect::(); + let cleaned = cleaned.trim_matches(|ch| ch == '.' || ch == ' ').to_string(); + + match cleaned.as_str() { + "" | "." | ".." => "download.html".to_string(), + _ => cleaned, + } +} + +fn unique_download_target_path(directory: &Path, file_name: &str) -> PathBuf { + let candidate = directory.join(file_name); + if !candidate.exists() { + return candidate; + } + + let (stem, extension) = split_file_name(file_name); + for suffix in 1..1_000 { + let file_name = match extension { + Some(extension) => format!("{stem} ({suffix}).{extension}"), + None => format!("{stem} ({suffix})"), + }; + let candidate = directory.join(file_name); + if !candidate.exists() { + return candidate; + } + } + + directory.join(format!("{stem} (1000)")) +} + +fn split_file_name(file_name: &str) -> (&str, Option<&str>) { + match file_name.rsplit_once('.') { + Some((stem, extension)) if !stem.is_empty() && !extension.is_empty() => { + (stem, Some(extension)) + } + _ => (file_name, None), + } +} + +#[cfg(test)] +mod tests { + use std::{error::Error, fs}; + + use ely_domain::UrlText; + + use super::{suggested_download_file_name, unique_download_target_path}; + + #[test] + fn suggested_download_file_name_prefers_url_leaf() -> Result<(), Box> { + let url = UrlText::parse("https://example.com/files/report.pdf?token=1")?; + + assert_eq!(suggested_download_file_name(&url, "Example"), "report.pdf"); + Ok(()) + } + + #[test] + fn suggested_download_file_name_uses_title_for_directory_urls() -> Result<(), Box> { + let url = UrlText::parse("https://example.com/articles/")?; + + assert_eq!(suggested_download_file_name(&url, "A/B: Research"), "A-B- Research.html"); + Ok(()) + } + + #[test] + fn unique_download_target_path_preserves_existing_files() -> Result<(), Box> { + let directory = std::env::temp_dir().join("ely-unique-download-test"); + fs::create_dir_all(&directory)?; + let existing = directory.join("report.pdf"); + fs::write(&existing, [])?; + + assert_eq!( + unique_download_target_path(&directory, "report.pdf"), + directory.join("report (1).pdf") + ); + + fs::remove_file(existing)?; + fs::remove_dir(directory)?; + Ok(()) + } +} diff --git a/crates/ely_app/src/shell/downloads.rs b/crates/ely_app/src/shell/downloads.rs index 95764ba..edecd45 100644 --- a/crates/ely_app/src/shell/downloads.rs +++ b/crates/ely_app/src/shell/downloads.rs @@ -1,11 +1,18 @@ -use ely_domain::DownloadId; -use gpui::Context; +use std::path::PathBuf; + +use ely_domain::{DownloadId, UrlText}; +use gpui::{Context, Window}; use crate::services::{ - download_checksums::DownloadChecksumCalculator, download_files::DownloadFileAction, + download_checksums::DownloadChecksumCalculator, + download_files::DownloadFileAction, + http_downloads::{HttpDownloadError, HttpDownloadPlan, HttpDownloadResult, download_to_file}, }; -use super::{ElyShell, ShellState}; +use super::{ + ElyShell, ShellState, + download_targets::{ActiveTabDownloadTarget, active_tab_download_target}, +}; #[derive(Clone, Debug)] pub(super) struct PendingDownloadFileAction { @@ -29,6 +36,57 @@ impl PendingDownloadFileAction { } impl ElyShell { + pub(super) fn download_active_tab(&mut self, window: &mut Window, cx: &mut Context) { + self.download_action_error = None; + let target = match &self.state { + ShellState::Ready(core) => active_tab_download_target(core), + ShellState::StartupError(message) => Err(message.clone()), + }; + let target = match target { + Ok(target) => target, + Err(message) => { + self.set_download_action_error(message, cx); + return; + } + }; + + match target { + ActiveTabDownloadTarget::Ready { url, target_path } => { + self.start_download_to_path(url, target_path, window, cx); + } + ActiveTabDownloadTarget::Prompt { url, directory, file_name } => { + let prompt = cx.prompt_for_new_path(&directory, Some(&file_name)); + + cx.spawn_in(window, async move |shell, window| { + let selected_path = match prompt.await { + Ok(Ok(path)) => path, + Ok(Err(error)) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_download_action_error(error.to_string(), cx); + }); + return; + } + Err(error) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_download_action_error(error.to_string(), cx); + }); + return; + } + }; + + let Some(path) = selected_path else { + return; + }; + + _ = shell.update_in(window, |shell, window, cx| { + shell.start_download_to_path(url, path, window, cx); + }); + }) + .detach(); + } + } + } + pub(super) fn pause_download(&mut self, download_id: &DownloadId, cx: &mut Context) { if let ShellState::Ready(core) = &mut self.state && core.pause_download(download_id).is_ok() @@ -103,6 +161,80 @@ impl ElyShell { cx.notify(); } + fn start_download_to_path( + &mut self, + source_url: UrlText, + target_path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + let download_id = match &mut self.state { + ShellState::Ready(core) => match core.record_download_started_at_path( + source_url.clone(), + target_path.clone(), + None, + ) { + Ok(download_id) => Ok(download_id), + Err(error) => Err(error.to_string()), + }, + ShellState::StartupError(message) => Err(message.clone()), + }; + + let download_id = match download_id { + Ok(download_id) => download_id, + Err(message) => { + self.set_download_action_error(message, cx); + return; + } + }; + + self.open_downloads(window, cx); + cx.notify(); + + let plan = HttpDownloadPlan::new(source_url, target_path); + cx.spawn_in(window, async move |shell, window| { + let result = + window.background_executor().spawn(async move { download_to_file(plan) }).await; + _ = shell.update_in(window, |shell, _, cx| { + shell.handle_http_download_result(download_id, result, cx); + }); + }) + .detach(); + } + + fn handle_http_download_result( + &mut self, + download_id: DownloadId, + result: Result, + cx: &mut Context, + ) { + let update_result = match (&mut self.state, result) { + (ShellState::Ready(core), Ok(result)) => core + .complete_download(&download_id, result.received_bytes()) + .map(|()| None) + .map_err(|error| error.to_string()), + (ShellState::Ready(core), Err(error)) => { + let message = error.to_string(); + match core.fail_download(&download_id) { + Ok(()) => Ok(Some(message)), + Err(state_error) => Err(format!("{message}; {state_error}")), + } + } + (ShellState::StartupError(message), _) => Err(message.clone()), + }; + + match update_result { + Ok(message) => self.download_action_error = message, + Err(message) => self.download_action_error = Some(message), + } + cx.notify(); + } + + fn set_download_action_error(&mut self, message: String, cx: &mut Context) { + self.download_action_error = Some(message); + cx.notify(); + } + pub(super) fn open_download_file(&mut self, download_id: &DownloadId, cx: &mut Context) { self.run_download_file_action(download_id, DownloadFileAction::Open, cx); } diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 841f697..8082829 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -2,6 +2,7 @@ mod archive_labels; mod bookmark_files; mod bookmarks; mod command_actions; +mod download_targets; mod downloads; mod focus; mod history; @@ -44,9 +45,9 @@ use plugins::{PendingPluginInstall, PendingPluginUninstall}; use web_surface::WebSurfaceStore; use crate::{ - CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab, - OpenSettings, OpenTaskManager, ResetZoom, RestoreClosedTab, SelectNextTab, SelectPreviousTab, - ToggleFavoriteTab, TogglePinnedTab, ZoomIn, ZoomOut, + CloseCurrentTab, DownloadCurrentPage, FocusAddressBar, FocusCommandMode, OpenDownloads, + OpenHistory, OpenNewTab, OpenSettings, OpenTaskManager, ResetZoom, RestoreClosedTab, + SelectNextTab, SelectPreviousTab, ToggleFavoriteTab, TogglePinnedTab, ZoomIn, ZoomOut, }; enum ShellState { @@ -348,6 +349,15 @@ impl ElyShell { self.open_downloads(window, cx); } + fn on_download_current_page( + &mut self, + _: &DownloadCurrentPage, + window: &mut Window, + cx: &mut Context, + ) { + self.download_active_tab(window, cx); + } + fn on_open_history(&mut self, _: &OpenHistory, window: &mut Window, cx: &mut Context) { self.open_history(window, cx); } diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index 0a272a8..dfdea86 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -46,6 +46,7 @@ impl ElyShell { .on_action(cx.listener(Self::on_close_current_tab)) .on_action(cx.listener(Self::on_focus_address_bar)) .on_action(cx.listener(Self::on_focus_command_mode)) + .on_action(cx.listener(Self::on_download_current_page)) .on_action(cx.listener(Self::on_open_downloads)) .on_action(cx.listener(Self::on_open_history)) .on_action(cx.listener(Self::on_open_new_tab)) diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index f941fa0..a5c5865 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -259,6 +259,12 @@ impl BrowserCore { self.open_tab(downloads_url()?); Ok(true) } + "download-current-page" + | "download current page" + | "save-page" + | "save page" + | "save-current-page" + | "save current page" => Ok(true), "bookmarks" | "open-bookmarks" | "open bookmarks" => { self.open_tab(bookmarks_url()?); Ok(true) diff --git a/crates/ely_browser_core/src/state/downloads.rs b/crates/ely_browser_core/src/state/downloads.rs index 2f807a1..517b0a9 100644 --- a/crates/ely_browser_core/src/state/downloads.rs +++ b/crates/ely_browser_core/src/state/downloads.rs @@ -27,6 +27,26 @@ impl BrowserCore { Ok(download_id) } + pub fn record_download_started_at_path( + &mut self, + source_url: UrlText, + target_file_path: impl Into, + total_bytes: Option, + ) -> Result { + let destination = self.active_profile()?.download_policy().destination().clone(); + let entry = DownloadEntry::started_at_path( + self.active_profile_id.clone(), + source_url, + destination, + target_file_path, + total_bytes, + SystemTime::now(), + )?; + let download_id = entry.id().clone(); + self.download_entries.push(entry); + Ok(download_id) + } + pub fn pause_download(&mut self, download_id: &DownloadId) -> Result<(), CoreError> { self.download_entry_mut(download_id)?.pause()?; Ok(()) diff --git a/crates/ely_browser_core/tests/commands.rs b/crates/ely_browser_core/tests/commands.rs index 92c72a0..d04333b 100644 --- a/crates/ely_browser_core/tests/commands.rs +++ b/crates/ely_browser_core/tests/commands.rs @@ -66,6 +66,22 @@ fn open_downloads_command_opens_downloads_page() -> Result<(), Box> { Ok(()) } +#[test] +fn download_current_page_command_keeps_active_tab_for_shell_download() -> Result<(), Box> +{ + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let active_tab_id = core.active_tab()?.id().clone(); + + core.set_command_query(">download-current-page"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("download-current-page".to_string()))); + assert_eq!(snapshot.active_tab_id, active_tab_id); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + #[test] fn open_history_command_opens_history_page() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_browser_core/tests/downloads.rs b/crates/ely_browser_core/tests/downloads.rs index b6f0d99..478dc4e 100644 --- a/crates/ely_browser_core/tests/downloads.rs +++ b/crates/ely_browser_core/tests/downloads.rs @@ -91,6 +91,25 @@ fn controls_download_lifecycle() -> Result<(), Box> { Ok(()) } +#[test] +fn records_prompted_download_target_path() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let target_path = Path::new("/tmp/ely-prompted-download/report.pdf"); + + let download_id = core.record_download_started_at_path( + UrlText::parse("https://example.com/report.pdf")?, + target_path, + None, + )?; + let entry = active_download(&core)?; + + assert_eq!(entry.id(), &download_id); + assert_eq!(entry.file_name(), "report.pdf"); + assert_eq!(entry.destination(), &DownloadDestination::AskEveryTime); + assert_eq!(entry.target_file_path(), Some(target_path)); + Ok(()) +} + #[test] fn records_checksum_after_download_completion() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_domain/src/download.rs b/crates/ely_domain/src/download.rs index b38eae1..07a6ece 100644 --- a/crates/ely_domain/src/download.rs +++ b/crates/ely_domain/src/download.rs @@ -218,6 +218,46 @@ impl DownloadEntry { }) } + pub fn started_at_path( + profile_id: ProfileId, + source_url: UrlText, + destination: DownloadDestination, + target_file_path: impl Into, + total_bytes: Option, + started_at: SystemTime, + ) -> Result { + let target_file_path = target_file_path.into(); + if !target_file_path.is_absolute() { + return Err(DomainError::InvalidDownloadTargetPath { + path: target_file_path.display().to_string(), + }); + } + + let Some(file_name) = target_file_path.file_name().and_then(|value| value.to_str()) else { + return Err(DomainError::InvalidDownloadTargetPath { + path: target_file_path.display().to_string(), + }); + }; + let file_name = file_name.to_string(); + validate_file_name(&file_name)?; + + Ok(Self { + id: DownloadId::new(), + profile_id, + source_url, + file_name: file_name.clone(), + destination, + target_file_path: Some(target_file_path), + security: DownloadSecurity::for_file_name(&file_name), + state: DownloadState::InProgress, + received_bytes: 0, + total_bytes, + checksum: None, + security_prompt_confirmed: false, + started_at, + }) + } + pub fn pause(&mut self) -> Result<(), DomainError> { self.require_state("pause", &[DownloadState::InProgress])?; self.state = DownloadState::Paused; diff --git a/crates/ely_domain/src/error.rs b/crates/ely_domain/src/error.rs index 9a57079..f69623b 100644 --- a/crates/ely_domain/src/error.rs +++ b/crates/ely_domain/src/error.rs @@ -29,6 +29,9 @@ pub enum DomainError { #[error("invalid download directory: {path}")] InvalidDownloadDirectory { path: String }, + #[error("invalid download target path: {path}")] + InvalidDownloadTargetPath { path: String }, + #[error("invalid command query")] InvalidCommand,