Add current page download pipeline
This commit is contained in:
Generated
+30
-3
@@ -614,7 +614,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tungstenite",
|
"tungstenite",
|
||||||
"webpki-roots",
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2256,6 +2256,8 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"toml 1.1.2+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
|
"ureq",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4009,7 +4011,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"webpki-roots",
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8910,7 +8912,7 @@ dependencies = [
|
|||||||
"tungstenite",
|
"tungstenite",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"webpki-roots",
|
"webpki-roots 1.0.7",
|
||||||
"webrender_api",
|
"webrender_api",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -11117,6 +11119,22 @@ version = "0.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
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]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -11662,6 +11680,15 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"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]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ serde = { version = "1.0.228", features = ["derive"] }
|
|||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
thiserror = "2.0.12"
|
thiserror = "2.0.12"
|
||||||
toml = "1.1.2"
|
toml = "1.1.2"
|
||||||
|
ureq = "2.12.1"
|
||||||
url = "2.5.4"
|
url = "2.5.4"
|
||||||
uuid = { version = "1.12.1", features = ["v7"] }
|
uuid = { version = "1.12.1", features = ["v7"] }
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
ureq.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ actions!(
|
|||||||
ely_app,
|
ely_app,
|
||||||
[
|
[
|
||||||
CloseCurrentTab,
|
CloseCurrentTab,
|
||||||
|
DownloadCurrentPage,
|
||||||
FocusAddressBar,
|
FocusAddressBar,
|
||||||
FocusCommandMode,
|
FocusCommandMode,
|
||||||
OpenDownloads,
|
OpenDownloads,
|
||||||
@@ -81,6 +82,9 @@ fn main() {
|
|||||||
MenuItem::action("Command Mode", FocusCommandMode),
|
MenuItem::action("Command Mode", FocusCommandMode),
|
||||||
MenuItem::action("Toggle Sidebar", ToggleSidebar),
|
MenuItem::action("Toggle Sidebar", ToggleSidebar),
|
||||||
MenuItem::separator(),
|
MenuItem::separator(),
|
||||||
|
MenuItem::action("Download Current Page", DownloadCurrentPage),
|
||||||
|
MenuItem::action("Open Downloads", OpenDownloads),
|
||||||
|
MenuItem::separator(),
|
||||||
MenuItem::action("Zoom In", ZoomIn),
|
MenuItem::action("Zoom In", ZoomIn),
|
||||||
MenuItem::action("Zoom Out", ZoomOut),
|
MenuItem::action("Zoom Out", ZoomOut),
|
||||||
MenuItem::action("Reset Zoom", ResetZoom),
|
MenuItem::action("Reset Zoom", ResetZoom),
|
||||||
@@ -92,7 +96,6 @@ fn main() {
|
|||||||
MenuItem::action("Next Space", SelectNextSpace),
|
MenuItem::action("Next Space", SelectNextSpace),
|
||||||
MenuItem::action("Previous Space", SelectPreviousSpace),
|
MenuItem::action("Previous Space", SelectPreviousSpace),
|
||||||
MenuItem::separator(),
|
MenuItem::separator(),
|
||||||
MenuItem::action("Open Downloads", OpenDownloads),
|
|
||||||
MenuItem::action("Open History", OpenHistory),
|
MenuItem::action("Open History", OpenHistory),
|
||||||
MenuItem::action("Open Task Manager", OpenTaskManager),
|
MenuItem::action("Open Task Manager", OpenTaskManager),
|
||||||
MenuItem::action("Open Settings", OpenSettings),
|
MenuItem::action("Open Settings", OpenSettings),
|
||||||
|
|||||||
@@ -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<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<ureq::Error>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[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<HttpDownloadResult, HttpDownloadError> {
|
||||||
|
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<u64, HttpDownloadError> {
|
||||||
|
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<ureq::Response, HttpDownloadError> {
|
||||||
|
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<u64> {
|
||||||
|
response.header("Content-Length").and_then(|value| value.parse().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_content_length(
|
||||||
|
url: &str,
|
||||||
|
received_bytes: u64,
|
||||||
|
total_bytes: Option<u64>,
|
||||||
|
) -> 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<Result<(), std::io::Error>>;
|
||||||
|
type SpawnedHttpServer = (String, TestServerHandle);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downloads_local_http_response_to_file() -> Result<(), Box<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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<u8>, status: u16) -> Result<SpawnedHttpServer, Box<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
match handle.join() {
|
||||||
|
Ok(result) => result.map_err(Into::into),
|
||||||
|
Err(_) => Err("HTTP server thread panicked".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_file_path(name: &str) -> Result<PathBuf, Box<dyn Error>> {
|
||||||
|
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
|
||||||
|
Ok(std::env::temp_dir().join(format!("ely-http-download-{name}-{nanos}.bin")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod download_checksums;
|
pub mod download_checksums;
|
||||||
pub mod download_files;
|
pub mod download_files;
|
||||||
|
pub mod http_downloads;
|
||||||
pub mod plugin_package_store;
|
pub mod plugin_package_store;
|
||||||
pub mod plugin_packages;
|
pub mod plugin_packages;
|
||||||
pub mod plugin_signatures;
|
pub mod plugin_signatures;
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ impl ElyShell {
|
|||||||
if install_plugin_from_file_command(command) {
|
if install_plugin_from_file_command(command) {
|
||||||
self.choose_plugin_package(window, cx);
|
self.choose_plugin_package(window, cx);
|
||||||
}
|
}
|
||||||
|
if download_current_page_command(command) {
|
||||||
|
self.download_active_tab(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
match space_file_command(command) {
|
match space_file_command(command) {
|
||||||
Some(SpaceFileCommand::ExportActiveSpace) => self.export_active_space(window, cx),
|
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<SpaceFileCommand> {
|
fn space_file_command(command: &str) -> Option<SpaceFileCommand> {
|
||||||
match command.trim().to_ascii_lowercase().as_str() {
|
match command.trim().to_ascii_lowercase().as_str() {
|
||||||
"export-space" | "export space" | "export-active-space" | "export active space" => {
|
"export-space" | "export space" | "export-active-space" | "export active space" => {
|
||||||
@@ -134,8 +149,8 @@ fn local_data_file_command(command: &str) -> Option<LocalDataFileCommand> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
BookmarkFileCommand, LocalDataFileCommand, ShortcutFileCommand, SpaceFileCommand,
|
BookmarkFileCommand, LocalDataFileCommand, ShortcutFileCommand, SpaceFileCommand,
|
||||||
bookmark_file_command, install_plugin_from_file_command, local_data_file_command,
|
bookmark_file_command, download_current_page_command, install_plugin_from_file_command,
|
||||||
shortcut_file_command, space_file_command,
|
local_data_file_command, shortcut_file_command, space_file_command,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -151,6 +166,13 @@ mod tests {
|
|||||||
assert!(!install_plugin_from_file_command("open plugins"));
|
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]
|
#[test]
|
||||||
fn space_file_command_matches_export_and_import_aliases() {
|
fn space_file_command_matches_export_and_import_aliases() {
|
||||||
assert_eq!(space_file_command("export-space"), Some(SpaceFileCommand::ExportActiveSpace));
|
assert_eq!(space_file_command("export-space"), Some(SpaceFileCommand::ExportActiveSpace));
|
||||||
|
|||||||
@@ -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<ActiveTabDownloadTarget, String> {
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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<String> {
|
||||||
|
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::<String>();
|
||||||
|
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<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
use ely_domain::DownloadId;
|
use std::path::PathBuf;
|
||||||
use gpui::Context;
|
|
||||||
|
use ely_domain::{DownloadId, UrlText};
|
||||||
|
use gpui::{Context, Window};
|
||||||
|
|
||||||
use crate::services::{
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub(super) struct PendingDownloadFileAction {
|
pub(super) struct PendingDownloadFileAction {
|
||||||
@@ -29,6 +36,57 @@ impl PendingDownloadFileAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ElyShell {
|
impl ElyShell {
|
||||||
|
pub(super) fn download_active_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
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<Self>) {
|
pub(super) fn pause_download(&mut self, download_id: &DownloadId, cx: &mut Context<Self>) {
|
||||||
if let ShellState::Ready(core) = &mut self.state
|
if let ShellState::Ready(core) = &mut self.state
|
||||||
&& core.pause_download(download_id).is_ok()
|
&& core.pause_download(download_id).is_ok()
|
||||||
@@ -103,6 +161,80 @@ impl ElyShell {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_download_to_path(
|
||||||
|
&mut self,
|
||||||
|
source_url: UrlText,
|
||||||
|
target_path: PathBuf,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
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<HttpDownloadResult, HttpDownloadError>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
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>) {
|
||||||
|
self.download_action_error = Some(message);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn open_download_file(&mut self, download_id: &DownloadId, cx: &mut Context<Self>) {
|
pub(super) fn open_download_file(&mut self, download_id: &DownloadId, cx: &mut Context<Self>) {
|
||||||
self.run_download_file_action(download_id, DownloadFileAction::Open, cx);
|
self.run_download_file_action(download_id, DownloadFileAction::Open, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod archive_labels;
|
|||||||
mod bookmark_files;
|
mod bookmark_files;
|
||||||
mod bookmarks;
|
mod bookmarks;
|
||||||
mod command_actions;
|
mod command_actions;
|
||||||
|
mod download_targets;
|
||||||
mod downloads;
|
mod downloads;
|
||||||
mod focus;
|
mod focus;
|
||||||
mod history;
|
mod history;
|
||||||
@@ -44,9 +45,9 @@ use plugins::{PendingPluginInstall, PendingPluginUninstall};
|
|||||||
use web_surface::WebSurfaceStore;
|
use web_surface::WebSurfaceStore;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab,
|
CloseCurrentTab, DownloadCurrentPage, FocusAddressBar, FocusCommandMode, OpenDownloads,
|
||||||
OpenSettings, OpenTaskManager, ResetZoom, RestoreClosedTab, SelectNextTab, SelectPreviousTab,
|
OpenHistory, OpenNewTab, OpenSettings, OpenTaskManager, ResetZoom, RestoreClosedTab,
|
||||||
ToggleFavoriteTab, TogglePinnedTab, ZoomIn, ZoomOut,
|
SelectNextTab, SelectPreviousTab, ToggleFavoriteTab, TogglePinnedTab, ZoomIn, ZoomOut,
|
||||||
};
|
};
|
||||||
|
|
||||||
enum ShellState {
|
enum ShellState {
|
||||||
@@ -348,6 +349,15 @@ impl ElyShell {
|
|||||||
self.open_downloads(window, cx);
|
self.open_downloads(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_download_current_page(
|
||||||
|
&mut self,
|
||||||
|
_: &DownloadCurrentPage,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.download_active_tab(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
fn on_open_history(&mut self, _: &OpenHistory, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_open_history(&mut self, _: &OpenHistory, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.open_history(window, cx);
|
self.open_history(window, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ impl ElyShell {
|
|||||||
.on_action(cx.listener(Self::on_close_current_tab))
|
.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_address_bar))
|
||||||
.on_action(cx.listener(Self::on_focus_command_mode))
|
.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_downloads))
|
||||||
.on_action(cx.listener(Self::on_open_history))
|
.on_action(cx.listener(Self::on_open_history))
|
||||||
.on_action(cx.listener(Self::on_open_new_tab))
|
.on_action(cx.listener(Self::on_open_new_tab))
|
||||||
|
|||||||
@@ -259,6 +259,12 @@ impl BrowserCore {
|
|||||||
self.open_tab(downloads_url()?);
|
self.open_tab(downloads_url()?);
|
||||||
Ok(true)
|
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" => {
|
"bookmarks" | "open-bookmarks" | "open bookmarks" => {
|
||||||
self.open_tab(bookmarks_url()?);
|
self.open_tab(bookmarks_url()?);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
|||||||
@@ -27,6 +27,26 @@ impl BrowserCore {
|
|||||||
Ok(download_id)
|
Ok(download_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn record_download_started_at_path(
|
||||||
|
&mut self,
|
||||||
|
source_url: UrlText,
|
||||||
|
target_file_path: impl Into<PathBuf>,
|
||||||
|
total_bytes: Option<u64>,
|
||||||
|
) -> Result<DownloadId, CoreError> {
|
||||||
|
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> {
|
pub fn pause_download(&mut self, download_id: &DownloadId) -> Result<(), CoreError> {
|
||||||
self.download_entry_mut(download_id)?.pause()?;
|
self.download_entry_mut(download_id)?.pause()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -66,6 +66,22 @@ fn open_downloads_command_opens_downloads_page() -> Result<(), Box<dyn Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_current_page_command_keeps_active_tab_for_shell_download() -> Result<(), Box<dyn Error>>
|
||||||
|
{
|
||||||
|
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]
|
#[test]
|
||||||
fn open_history_command_opens_history_page() -> Result<(), Box<dyn Error>> {
|
fn open_history_command_opens_history_page() -> Result<(), Box<dyn Error>> {
|
||||||
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||||
|
|||||||
@@ -91,6 +91,25 @@ fn controls_download_lifecycle() -> Result<(), Box<dyn Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn records_prompted_download_target_path() -> Result<(), Box<dyn Error>> {
|
||||||
|
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]
|
#[test]
|
||||||
fn records_checksum_after_download_completion() -> Result<(), Box<dyn Error>> {
|
fn records_checksum_after_download_completion() -> Result<(), Box<dyn Error>> {
|
||||||
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||||
|
|||||||
@@ -218,6 +218,46 @@ impl DownloadEntry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn started_at_path(
|
||||||
|
profile_id: ProfileId,
|
||||||
|
source_url: UrlText,
|
||||||
|
destination: DownloadDestination,
|
||||||
|
target_file_path: impl Into<PathBuf>,
|
||||||
|
total_bytes: Option<u64>,
|
||||||
|
started_at: SystemTime,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
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> {
|
pub fn pause(&mut self) -> Result<(), DomainError> {
|
||||||
self.require_state("pause", &[DownloadState::InProgress])?;
|
self.require_state("pause", &[DownloadState::InProgress])?;
|
||||||
self.state = DownloadState::Paused;
|
self.state = DownloadState::Paused;
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ pub enum DomainError {
|
|||||||
#[error("invalid download directory: {path}")]
|
#[error("invalid download directory: {path}")]
|
||||||
InvalidDownloadDirectory { path: String },
|
InvalidDownloadDirectory { path: String },
|
||||||
|
|
||||||
|
#[error("invalid download target path: {path}")]
|
||||||
|
InvalidDownloadTargetPath { path: String },
|
||||||
|
|
||||||
#[error("invalid command query")]
|
#[error("invalid command query")]
|
||||||
InvalidCommand,
|
InvalidCommand,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user