fix(core): harden download lifecycle and scheduling

This commit is contained in:
NimBold
2026-06-22 11:19:18 +03:30
parent d535bdac8f
commit 06b14df307
38 changed files with 1257 additions and 428 deletions
+1 -1
View File
@@ -333,7 +333,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.4.0] - 2026-06-03 ## [0.4.0] - 2026-06-03
### Changes ### Changes
- Reorganized Settings sections so related download preferences sit together and app diagnostics live under App. - Reorganized Settings sections so related download preferences sit together and app logs live under App.
- Hardened the release workflow with explicit macOS 26 SDK checks, newer GitHub Actions, and app signature verification. - Hardened the release workflow with explicit macOS 26 SDK checks, newer GitHub Actions, and app signature verification.
- Prefer the bundled `aria2c` binary inside release builds. - Prefer the bundled `aria2c` binary inside release builds.
+3 -3
View File
@@ -182,7 +182,7 @@ users. Manual QA is needed to confirm current focus behavior.
| SB-09 | Delete Queue | queue context menu | `removeQueue` | DB persistence subscription | Reassign items to Main Queue and delete custom queue | wired; no confirmation | unit test | | SB-09 | Delete Queue | queue context menu | `removeQueue` | DB persistence subscription | Reassign items to Main Queue and delete custom queue | wired; no confirmation | unit test |
| SB-10 | Open Scheduler | `ToolItem` | `setActiveView` | none | Show Scheduler | wired | code trace | | SB-10 | Open Scheduler | `ToolItem` | `setActiveView` | none | Show Scheduler | wired | code trace |
| SB-11 | Open Speed Limiter | `ToolItem` | `setActiveView` | none | Show Speed Limiter | wired | code trace | | SB-11 | Open Speed Limiter | `ToolItem` | `setActiveView` | none | Show Speed Limiter | wired | code trace |
| SB-12 | Open Diagnostics | `ToolItem` | `setActiveView` | none | Show Diagnostics | wired | code trace | | SB-12 | Open Logs | `ToolItem` | `setActiveView` | none | Show Logs | wired | code trace |
| SB-13 | Open Settings | footer button | `setActiveView` | none | Show Settings | wired | code trace | | SB-13 | Open Settings | footer button | `setActiveView` | none | Show Settings | wired | code trace |
Recommendation: make queue membership explicit and total. Every non-completed Recommendation: make queue membership explicit and total. Every non-completed
@@ -351,7 +351,7 @@ to the frontend.
|---|---|---|---|---|---| |---|---|---|---|---|---|
| ST-25 | Prevent system sleep | setter plus download-state sync; `set_prevent_sleep` | Keep system awake during active downloads | wired but duplicated in setter/store synchronization | integration test, Manual QA needed | | ST-25 | Prevent system sleep | setter plus download-state sync; `set_prevent_sleep` | Keep system awake during active downloads | wired but duplicated in setter/store synchronization | integration test, Manual QA needed |
| ST-26 | Recheck engines | four engine status commands | Validate packaged sidecars | wired | integration test, build check | | ST-26 | Recheck engines | four engine status commands | Validate packaged sidecars | wired | integration test, build check |
| ST-27 | Show/hide engine details | local expanded state | Reveal diagnostics | wired | code trace | | ST-27 | Show/hide engine details | local expanded state | Reveal engine status | wired | code trace |
| ST-28 | Browser Cookies Source | `setMediaCookieSource` | Pass selected browser to media metadata/downloads | wired; Manual QA needed for browser permissions | integration test | | ST-28 | Browser Cookies Source | `setMediaCookieSource` | Pass selected browser to media metadata/downloads | wired; Manual QA needed for browser permissions | integration test |
| ST-29 | Copy pairing token | clipboard handler | Copy keychain-hydrated token | wired; success toast is shown without awaiting clipboard result | code trace, Manual QA needed | | ST-29 | Copy pairing token | clipboard handler | Copy keychain-hydrated token | wired; success toast is shown without awaiting clipboard result | code trace, Manual QA needed |
| ST-30 | Regenerate pairing token | `regeneratePairingToken`; keychain + App effect | Rotate token and reconfigure local server | wired but fragile: UI reports success before keychain/server calls confirm | integration test | | ST-30 | Regenerate pairing token | `regeneratePairingToken`; keychain + App effect | Rotate token and reconfigure local server | wired but fragile: UI reports success before keychain/server calls confirm | integration test |
@@ -401,7 +401,7 @@ native transfers that the command does not implement.
--- ---
## 8. Diagnostics ## 8. Logs
| ID | Action | Component / function | IPC / Rust | Expected behavior | Status | Validation | | ID | Action | Component / function | IPC / Rust | Expected behavior | Status | Validation |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
+173
View File
@@ -0,0 +1,173 @@
# YouTube and Media Download Handoff
## Repository state
The YouTube and media fixes were committed and pushed in:
- `a8b7920 fix(media): harden YouTube metadata loading`
- `340ef09 fix(media): correct YouTube size estimates`
- `226c791 fix(media): correct HLS progress tracking`
Do not discard unrelated worktree changes while working in this area.
## Non-negotiable architecture
### Keep the self-contained yt-dlp onedir distribution
Do not replace the bundled onedir distribution with yt-dlp onefile or a system
yt-dlp installation.
The onefile executable incurred roughly 17 seconds of extraction and startup
latency. Users cannot be expected to have Python or yt-dlp installed or
available through `PATH`.
The packaged layout must contain:
- `yt-dlp-<target>`
- The adjacent `_internal/` directory
- The embedded Python runtime
- The `yt_dlp_ejs` solver files
`scripts/verify-binaries.js` enforces this layout. Cross-platform builds must
provide an equivalent onedir distribution for each target. Do not implement
cross-platform support by falling back to a user-managed `PATH`.
### Keep metadata loading deterministic
The metadata implementation in `src-tauri/src/lib.rs` deliberately:
- Resolves only the bundled yt-dlp executable.
- Passes bundled Deno and FFmpeg by absolute path.
- Uses a minimal system `PATH`.
- Uses `--skip-download`.
- Requests only `title`, `duration`, `thumbnail`, and `formats`.
- Includes the cookie browser and credentials in the cache key.
- Deduplicates concurrent identical requests.
- Caches successful metadata for 60 seconds.
- Limits the cache to 128 entries.
Frontend request deduplication also exists in
`src/utils/mediaMetadata.ts`. Do not remove either deduplication layer. React
development behavior can otherwise start duplicate yt-dlp processes.
Deno is a JavaScript runtime used by yt-dlp extractors. It is not the metadata
engine.
### Keep media-format interpretation in the backend
`build_media_format_options` in `src-tauri/src/lib.rs` is the source of truth.
Do not reintroduce a separate format parser in `AddDownloadsModal.tsx`.
Duplicating this logic caused format selection and size estimation to drift.
Required invariants:
- Do not add a synthetic `Best` option.
- Exclude storyboards, MHTML, thumbnails, subtitles, and non-media entries.
- Match resolutions exactly. A 1080p stream must never produce a 1440p row.
- Bind each displayed option to concrete yt-dlp stream IDs, such as `301+251`.
- MKV, MP4, and WebM options must describe the streams actually selected.
- Do not add a separate audio stream when the selected video already has audio.
### Keep size values honest
Size semantics:
- `filesize` is exact.
- `filesize_approx` is approximate.
- When yt-dlp provides neither, estimate from bitrate multiplied by duration
and mark the result approximate.
- For split formats, combine the video and audio sizes.
- Approximate sizes in the UI must have a `~` prefix.
- Temporary component-stream totals must not overwrite the download row's
estimate.
- Successful completion must replace the estimate with the actual output file
size read from disk.
- Stores may treat a progress size as authoritative only when
`size_is_final` is true.
The primary UI consumers are:
- `src/components/AddDownloadsModal.tsx`
- `src/components/QualityModal.tsx`
### Do not trust temporary HLS byte totals for progress
yt-dlp can initially emit data similar to:
```text
downloaded_bytes=1024
total_bytes_estimate=1024
fragment_index=0
fragment_count=354
_percent_str=100.0%
```
This does not mean the download is complete. It represents an early HLS
fragment with a temporary size estimate.
`parse_media_progress_line` must therefore prefer:
```text
fragment_index / fragment_count
```
when fragment information is available.
For separate video and audio streams, `aggregate_media_fraction` advances to
the next track only when the previous track was effectively complete and the
new track restarts near zero. Do not restore the old heuristic that treated any
large percentage drop as a track transition.
Visible speed is derived from downloaded-byte deltas where possible. Raw
yt-dlp speed is only a fallback.
## Required regression checks
Before accepting changes to yt-dlp arguments, binary packaging, metadata,
progress, sizes, format selection, pause/resume, or the media UI, run:
```bash
cargo test --all-targets
npm run build
npm test -- --run
node scripts/verify-binaries.js
git diff --check
```
Run the live format smoke with a currently available multi-quality video:
```bash
FIRELINK_LIVE_YOUTUBE_URL='https://www.youtube.com/watch?v=<id>' \
cargo test filters_live_youtube_metadata_from_env --lib -- --ignored --nocapture
```
Preserve these Rust regression tests:
- `builds_compact_media_options_without_storyboards`
- `estimates_missing_video_size_from_bitrate_and_uses_exact_stream_ids`
- `uses_fragment_progress_instead_of_temporary_hls_size_estimates`
- `advances_tracks_only_after_a_completed_track_restarts`
- `derives_main_window_speed_from_downloaded_byte_delta`
- The structured yt-dlp, aria2, and legacy progress parser tests
## Manual packaged-app checks
Test the packaged application, not only development mode:
1. Launch it outside the repository working directory.
2. Select a browser cookie source.
3. Paste a YouTube URL into Add Downloads.
4. Confirm media formats appear within the expected warm-start budget, with a
target below eight seconds.
5. Confirm there is no `Best` row.
6. Confirm each displayed quality matches its actual resolution.
7. Confirm approximate sizes include `~`.
8. Start a split-stream or HLS download.
9. Confirm progress increases gradually instead of immediately reaching 100%.
10. Confirm the final displayed size equals the completed file's size on disk.
11. Pause and resume, confirming that the selected stream IDs and estimated
size remain intact.
Do not treat a deleted, private, age-restricted, or geographically unavailable
test video as proof of a product regression. Reproduce the problem with another
public multi-quality video first.
+1
View File
@@ -8,6 +8,7 @@
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"log:default",
"notification:default", "notification:default",
"notification:allow-is-permission-granted" "notification:allow-is-permission-granted"
] ]
+3 -44
View File
@@ -7,7 +7,7 @@ pub async fn reveal_in_file_manager(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
path: String, path: String,
) -> Result<(), String> { ) -> Result<(), String> {
let primary = authorize_download_path(&app_handle, &path, DownloadAsset::Primary)?; let primary = authorize_download_path(&app_handle, &path)?;
let path = existing_download_asset(&primary).ok_or_else(|| { let path = existing_download_asset(&primary).ok_or_else(|| {
format!( format!(
"Downloaded file or partial file is missing: {}", "Downloaded file or partial file is missing: {}",
@@ -28,7 +28,7 @@ pub async fn open_downloaded_file(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
path: String, path: String,
) -> Result<(), String> { ) -> Result<(), String> {
let path = authorize_download_path(&app_handle, &path, DownloadAsset::Primary)?; let path = authorize_download_path(&app_handle, &path)?;
if !path.exists() { if !path.exists() {
return Err(format!("Downloaded file is missing: {}", path.display())); return Err(format!("Downloaded file is missing: {}", path.display()));
} }
@@ -41,52 +41,11 @@ pub async fn open_downloaded_file(
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn trash_download_assets(
app_handle: tauri::AppHandle,
path: String,
partial_paths: Vec<String>,
) -> Result<(), String> {
let primary = authorize_download_path(&app_handle, &path, DownloadAsset::Primary)?;
let partials = partial_paths
.iter()
.map(|partial| authorize_download_path(&app_handle, partial, DownloadAsset::Partial))
.collect::<Result<Vec<_>, _>>()?;
if primary.exists() {
trash::delete(&primary).map_err(|e| format!("Failed to trash primary file: {}", e))?;
}
for partial in partials {
if partial.exists() {
trash::delete(&partial).map_err(|e| format!("Failed to trash partial file: {}", e))?;
}
}
Ok(())
}
#[derive(Clone, Copy)]
enum DownloadAsset {
Primary,
Partial,
}
fn authorize_download_path( fn authorize_download_path(
app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle,
requested: &str, requested: &str,
asset: DownloadAsset,
) -> Result<PathBuf, String> { ) -> Result<PathBuf, String> {
let known_paths = known_download_paths(app_handle)?; authorize_exact_path(Path::new(requested), &known_download_paths(app_handle)?)
let allowed_paths = match asset {
DownloadAsset::Primary => known_paths,
DownloadAsset::Partial => known_paths
.iter()
.flat_map(|path| [append_suffix(path, ".aria2"), append_suffix(path, ".part")])
.collect(),
};
authorize_exact_path(Path::new(requested), &allowed_paths)
} }
fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> { fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
+5 -2
View File
@@ -26,7 +26,7 @@ pub enum DownloadCmd {
Start(Box<DownloadPayload>), Start(Box<DownloadPayload>),
Pause(Uuid), Pause(Uuid),
PauseWithAck(Uuid, tokio::sync::oneshot::Sender<()>), PauseWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
Cancel(Uuid), CancelWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
CaptureUrls(Vec<String>), CaptureUrls(Vec<String>),
FrontendReady(bool), FrontendReady(bool),
} }
@@ -338,9 +338,12 @@ async fn run_coordinator(
let _ = ack.send(()); let _ = ack.send(());
} }
} }
DownloadCmd::Cancel(id) => { DownloadCmd::CancelWithAck(id, ack) => {
if let Some(download) = active.remove(&id) { if let Some(download) = active.remove(&id) {
let _ = download.control_tx.send(DownloadControl::Cancel).await; let _ = download.control_tx.send(DownloadControl::Cancel).await;
pending_acks.insert(id, ack);
} else {
let _ = ack.send(());
} }
} }
DownloadCmd::CaptureUrls(urls) => { DownloadCmd::CaptureUrls(urls) => {
+47 -4
View File
@@ -9,6 +9,30 @@ struct DownloadOwnershipRecord {
primary_path: String, primary_path: String,
} }
pub fn canonical_download_filename(filename: &str) -> String {
let leaf = filename.replace('\\', "/");
let leaf = Path::new(&leaf)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("download");
let sanitized = leaf
.chars()
.map(|character| {
if character.is_control() || matches!(character, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
'-'
} else {
character
}
})
.collect::<String>();
let sanitized = sanitized.trim().trim_end_matches(['.', ' ']);
if sanitized.is_empty() || matches!(sanitized, "." | "..") {
"download".to_string()
} else {
sanitized.to_string()
}
}
pub fn expected_primary_path( pub fn expected_primary_path(
app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle,
destination: &str, destination: &str,
@@ -19,10 +43,7 @@ pub fn expected_primary_path(
return Err("Path traversal blocked".to_string()); return Err("Path traversal blocked".to_string());
} }
let safe_filename = Path::new(&filename.replace('\\', "/")) let safe_filename = canonical_download_filename(filename);
.file_name()
.ok_or_else(|| "Download filename is invalid".to_string())?
.to_owned();
Ok(resolved_dest.join(safe_filename)) Ok(resolved_dest.join(safe_filename))
} }
@@ -67,6 +88,16 @@ pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
crate::db::remove_ownership(&connection, id) crate::db::remove_ownership(&connection, id)
} }
pub fn primary_path_for_id(
app_handle: &tauri::AppHandle,
id: &str,
) -> Result<Option<PathBuf>, String> {
Ok(load_records(app_handle)?
.into_iter()
.find(|record| record.id == id)
.map(|record| PathBuf::from(record.primary_path)))
}
pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> { pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
let mut paths: Vec<PathBuf> = load_records(app_handle)? let mut paths: Vec<PathBuf> = load_records(app_handle)?
.into_iter() .into_iter()
@@ -168,3 +199,15 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
Ok(paths) Ok(paths)
} }
#[cfg(test)]
mod tests {
use super::canonical_download_filename;
#[test]
fn canonicalizes_untrusted_download_filenames() {
assert_eq!(canonical_download_filename("../folder/video?.mp4"), "video-.mp4");
assert_eq!(canonical_download_filename(" report. "), "report");
assert_eq!(canonical_download_filename(".."), "download");
}
}
+17 -1
View File
@@ -8,6 +8,8 @@ use ts_rs::TS;
pub enum DownloadStatus { pub enum DownloadStatus {
/// Added to the download list but not assigned to a queue or dispatched. /// Added to the download list but not assigned to a queue or dispatched.
Ready, Ready,
/// Assigned to a queue but intentionally not registered with the backend.
Staged,
Downloading, Downloading,
/// Post-download media processing such as yt-dlp/ffmpeg merging or /// Post-download media processing such as yt-dlp/ffmpeg merging or
/// extraction. The queue permit is still held. /// extraction. The queue permit is still held.
@@ -25,6 +27,7 @@ impl DownloadStatus {
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Self::Ready => "ready", Self::Ready => "ready",
Self::Staged => "staged",
Self::Downloading => "downloading", Self::Downloading => "downloading",
Self::Processing => "processing", Self::Processing => "processing",
Self::Paused => "paused", Self::Paused => "paused",
@@ -100,6 +103,8 @@ pub struct DownloadItem {
#[ts(optional)] #[ts(optional)]
pub queue_id: Option<String>, pub queue_id: Option<String>,
#[ts(optional)] #[ts(optional)]
pub queue_position: Option<i32>,
#[ts(optional)]
pub has_been_dispatched: Option<bool>, pub has_been_dispatched: Option<bool>,
} }
@@ -110,9 +115,19 @@ pub struct EnqueueResult {
pub id: String, pub id: String,
pub success: bool, pub success: bool,
#[ts(optional)] #[ts(optional)]
pub filename: Option<String>,
#[ts(optional)]
pub error: Option<String>, pub error: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct EnqueueAccepted {
pub id: String,
pub filename: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)] #[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")] #[ts(export, export_to = "../../src/bindings/")]
@@ -170,7 +185,7 @@ pub enum ActiveView {
Scheduler, Scheduler,
#[serde(rename = "speedLimiter")] #[serde(rename = "speedLimiter")]
SpeedLimiter, SpeedLimiter,
Diagnostics, Logs,
} }
#[derive(Clone, Debug, Serialize, Deserialize, TS)] #[derive(Clone, Debug, Serialize, Deserialize, TS)]
@@ -219,6 +234,7 @@ pub struct SchedulerSettings {
pub stop_time: String, pub stop_time: String,
pub everyday: bool, pub everyday: bool,
pub selected_days: Vec<u32>, pub selected_days: Vec<u32>,
pub selected_queue_ids: Vec<String>,
pub post_queue_action: PostQueueAction, pub post_queue_action: PostQueueAction,
} }
+235 -77
View File
@@ -665,6 +665,14 @@ fn aggregate_media_fraction(
} }
async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) { async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) {
cleanup_media_artifacts(out_path, true).await;
}
async fn cleanup_media_sidecars(out_path: &std::path::Path) {
cleanup_media_artifacts(out_path, false).await;
}
async fn cleanup_media_artifacts(out_path: &std::path::Path, remove_primary: bool) {
let Some(parent) = out_path.parent() else { let Some(parent) = out_path.parent() else {
return; return;
}; };
@@ -676,7 +684,9 @@ async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) {
.and_then(|name| name.to_str()) .and_then(|name| name.to_str())
.unwrap_or(base_name); .unwrap_or(base_name);
let _ = tokio::fs::remove_file(out_path).await; if remove_primary {
let _ = tokio::fs::remove_file(out_path).await;
}
let Ok(mut entries) = tokio::fs::read_dir(parent).await else { let Ok(mut entries) = tokio::fs::read_dir(parent).await else {
return; return;
@@ -1425,7 +1435,7 @@ async fn test_aria2c(app_handle: tauri::AppHandle, state: tauri::State<'_, AppSt
.ok_or_else(|| "aria2 returned an invalid version response".to_string()) .ok_or_else(|| "aria2 returned an invalid version response".to_string())
} }
// ── get_engine_status: Structured engine diagnostics ────────────── // ── get_engine_status: Structured engine status ──────────────
async fn run_sidecar_version( async fn run_sidecar_version(
app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle,
@@ -1915,11 +1925,7 @@ pub(crate) async fn start_media_download_internal(
max_tries: Option<i32>, max_tries: Option<i32>,
cancel_rx: &mut tokio::sync::watch::Receiver<bool>, cancel_rx: &mut tokio::sync::watch::Receiver<bool>,
) -> Result<(), String> { ) -> Result<(), String> {
let safe_filename = std::path::Path::new(&filename.replace('\\', "/")) let safe_filename = crate::download_ownership::canonical_download_filename(&filename);
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
let resolved_dest = resolve_path(&destination, &app_handle); let resolved_dest = resolve_path(&destination, &app_handle);
@@ -2413,11 +2419,10 @@ async fn remove_download(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
id: String, id: String,
filepath: Option<String>, delete_assets: bool,
) -> Result<(), String> { ) -> Result<(), String> {
log::info!("remove_download called for id: {}", id); log::info!("remove_download called for id: {}", id);
let _ = crate::download_ownership::remove(&app_handle, &id); let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?;
state.queue_manager.release_registered_id(&id).await;
let active_kind = state.queue_manager.active_kind(&id).await; let active_kind = state.queue_manager.active_kind(&id).await;
state.queue_manager.remove_from_pending(&id).await; state.queue_manager.remove_from_pending(&id).await;
@@ -2447,9 +2452,8 @@ async fn remove_download(
} else if let Ok(download_id) = Uuid::parse_str(&id) { } else if let Ok(download_id) = Uuid::parse_str(&id) {
state state
.download_coordinator .download_coordinator
.send(download::DownloadCmd::Cancel(download_id)) .send(download::DownloadCmd::CancelWithAck(download_id, tx))
.await?; .await?;
let _ = tx.send(());
} else { } else {
let _ = tx.send(()); let _ = tx.send(());
} }
@@ -2468,41 +2472,61 @@ async fn remove_download(
crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused), crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused),
); );
if let Some(path) = filepath { if delete_assets {
if !path.is_empty() { if let Some(path) = primary_path.as_deref() {
let p = std::path::Path::new(&path); remove_download_assets(path, &app_handle).await?;
if is_safe_path(p, &app_handle) { }
if p.exists() { } else if let Some(path) = primary_path.as_deref() {
let _ = tokio::fs::remove_file(p).await; remove_partial_download_assets(path, &app_handle).await?;
} }
let aria2_path = format!("{}.aria2", path);
let p_aria2 = std::path::Path::new(&aria2_path);
if p_aria2.exists() {
let _ = tokio::fs::remove_file(p_aria2).await;
}
if let Some(parent) = p.parent() { crate::download_ownership::remove(&app_handle, &id)?;
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) { state.queue_manager.release_registered_id(&id).await;
let stem_with_dot = format!("{}.", stem); Ok(())
if let Ok(mut entries) = tokio::fs::read_dir(parent).await { }
while let Ok(Some(entry)) = entries.next_entry().await {
if let Ok(file_name) = entry.file_name().into_string() { async fn remove_download_assets(
if file_name.starts_with(&stem_with_dot) && primary: &std::path::Path,
(file_name.ends_with(".part") || file_name.ends_with(".ytdl") || file_name.ends_with(".aria2")) { app_handle: &tauri::AppHandle,
let path_to_remove = entry.path(); ) -> Result<(), String> {
if is_safe_path(&path_to_remove, &app_handle) { if !is_safe_path(primary, app_handle) {
let _ = tokio::fs::remove_file(path_to_remove).await; return Err("Download asset path is outside an allowed download location".to_string());
} }
}
} if primary.exists() {
} trash::delete(primary)
} .map_err(|error| format!("failed to move downloaded file to Trash: {error}"))?;
} }
}
} for suffix in [".aria2", ".part", ".ytdl"] {
let candidate = std::path::PathBuf::from(format!("{}{}", primary.display(), suffix));
if candidate.exists() && is_safe_path(&candidate, app_handle) {
tokio::fs::remove_file(&candidate)
.await
.map_err(|error| format!("failed to remove '{}': {error}", candidate.display()))?;
} }
} }
cleanup_media_processing_artifacts(primary).await;
Ok(())
}
async fn remove_partial_download_assets(
primary: &std::path::Path,
app_handle: &tauri::AppHandle,
) -> Result<(), String> {
if !is_safe_path(primary, app_handle) {
return Err("Download asset path is outside an allowed download location".to_string());
}
for suffix in [".aria2", ".part", ".ytdl"] {
let candidate = std::path::PathBuf::from(format!("{}{}", primary.display(), suffix));
if candidate.exists() && is_safe_path(&candidate, app_handle) {
tokio::fs::remove_file(&candidate)
.await
.map_err(|error| format!("failed to remove '{}': {error}", candidate.display()))?;
}
}
cleanup_media_sidecars(primary).await;
Ok(()) Ok(())
} }
@@ -2703,17 +2727,43 @@ fn perform_system_action(action: crate::ipc::PostQueueAction) -> Result<(), Stri
} }
#[tauri::command] #[tauri::command]
async fn get_pending_order(state: tauri::State<'_, AppState>) -> Result<Vec<String>, AppError> { fn ack_schedule_trigger(
Ok(state.queue_manager.pending_order().await) app_handle: tauri::AppHandle,
action: String,
key: String,
) -> Result<(), String> {
crate::settings::update_settings_state(&app_handle, |state| match action.as_str() {
"start" => {
state.insert("schedulerLastStartKey".to_string(), serde_json::json!(key));
}
"stop" => {
state.insert("schedulerLastStopKey".to_string(), serde_json::json!(key));
}
_ => {}
})?;
match action.as_str() {
"start" | "stop" => Ok(()),
_ => Err("Unknown scheduler trigger action".to_string()),
}
}
#[tauri::command]
async fn get_pending_order(
state: tauri::State<'_, AppState>,
queue_id: Option<String>,
) -> Result<Vec<String>, AppError> {
Ok(state.queue_manager.pending_order(queue_id.as_deref()).await)
} }
#[tauri::command] #[tauri::command]
async fn enqueue_download( async fn enqueue_download(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
item: queue::EnqueueItem, mut item: queue::EnqueueItem,
) -> Result<String, AppError> { ) -> Result<crate::ipc::EnqueueAccepted, AppError> {
let id = item.id.clone(); let id = item.id.clone();
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
let accepted_filename = item.filename.clone();
crate::download_ownership::register_expected( crate::download_ownership::register_expected(
&app_handle, &app_handle,
&item.id, &item.id,
@@ -2725,15 +2775,21 @@ async fn enqueue_download(
state.queue_manager.release_registered_id(&id).await; state.queue_manager.release_registered_id(&id).await;
return Err(AppError::Internal(e)); return Err(AppError::Internal(e));
} }
Ok(id) Ok(crate::ipc::EnqueueAccepted {
id,
filename: accepted_filename,
})
} }
#[tauri::command] #[tauri::command]
async fn enqueue_many( async fn enqueue_many(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
items: Vec<queue::EnqueueItem>, mut items: Vec<queue::EnqueueItem>,
) -> Result<Vec<crate::ipc::EnqueueResult>, AppError> { ) -> Result<Vec<crate::ipc::EnqueueResult>, AppError> {
for item in &mut items {
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
}
for item in &items { for item in &items {
crate::download_ownership::register_expected( crate::download_ownership::register_expected(
&app_handle, &app_handle,
@@ -2759,9 +2815,13 @@ async fn enqueue_many(
async fn move_in_queue( async fn move_in_queue(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
id: String, id: String,
queue_id: String,
direction: crate::ipc::QueueDirection, direction: crate::ipc::QueueDirection,
) -> Result<Vec<String>, AppError> { ) -> Result<Vec<String>, AppError> {
Ok(state.queue_manager.move_in_queue(&id, direction).await) Ok(state
.queue_manager
.move_in_queue(&id, &queue_id, direction)
.await)
} }
#[tauri::command] #[tauri::command]
@@ -3010,26 +3070,120 @@ fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String>
} }
} }
#[tauri::command] async fn log_files(app_handle: &tauri::AppHandle) -> Result<Vec<std::path::PathBuf>, String> {
async fn export_logs(app_handle: tauri::AppHandle, dest_path: String) -> Result<String, String> {
use tauri::Manager; use tauri::Manager;
let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?; let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?;
let log_file = log_dir.join("firelink.log"); let mut files = Vec::new();
let src = if log_file.exists() { if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await {
log_file while let Ok(Some(entry)) = entries.next_entry().await {
} else { let path = entry.path();
let mut found = None; if path.is_file()
if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await { && path
while let Ok(Some(entry)) = entries.next_entry().await { .file_name()
if entry.path().extension().is_some_and(|e| e == "log") { .is_some_and(|name| name.to_string_lossy().contains(".log"))
found = Some(entry.path()); {
break; files.push(path);
}
} }
} }
found.ok_or_else(|| "No log file found in app log directory".to_string())? }
}; files.sort();
tokio::fs::copy(&src, &dest_path).await.map_err(|e| e.to_string())?; Ok(files)
}
fn redact_log_line(line: &str) -> String {
use std::sync::OnceLock;
static SECRET: OnceLock<regex::Regex> = OnceLock::new();
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
let secret = SECRET.get_or_init(|| {
regex::Regex::new(
r"(?i)(authorization|cookie|password|token|secret)\s*[:=]\s*([^\s,;]+)",
)
.expect("valid secret redaction regex")
});
let query = QUERY.get_or_init(|| {
regex::Regex::new(r"(https?://[^\s?]+)\?[^\s]+")
.expect("valid URL query redaction regex")
});
let redacted = secret.replace_all(line, "$1=[redacted]");
query.replace_all(&redacted, "$1?[redacted]").into_owned()
}
#[tauri::command]
async fn read_logs(app_handle: tauri::AppHandle, limit: usize) -> Result<Vec<String>, String> {
let mut lines = Vec::new();
for file in log_files(&app_handle).await? {
let content = tokio::fs::read_to_string(&file)
.await
.map_err(|error| format!("failed to read '{}': {error}", file.display()))?;
lines.extend(content.lines().map(redact_log_line));
}
let keep = limit.clamp(1, 10_000);
if lines.len() > keep {
lines.drain(..lines.len() - keep);
}
Ok(lines)
}
#[tauri::command]
async fn export_logs(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
dest_path: String,
) -> Result<String, String> {
let mut output = format!(
"Firelink support logs\nVersion: {}\nOS: {} {}\nArchitecture: {}\nGenerated: {}\n\n",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::FAMILY,
std::env::consts::ARCH,
chrono::Utc::now().to_rfc3339(),
);
let (aria2, ytdlp, ffmpeg, deno) = tokio::join!(
check_aria2(&app_handle, state.aria2_port, &state.aria2_secret),
check_ytdlp(&app_handle),
check_ffmpeg(&app_handle),
check_deno(&app_handle),
);
output.push_str("Engine status:\n");
for engine in [aria2, ytdlp, ffmpeg, deno] {
output.push_str(&format!(
"- {}: {}{}\n",
engine.name,
if engine.ready { "ready" } else { "unavailable" },
engine
.version
.as_deref()
.map(|version| format!(" ({version})"))
.unwrap_or_default()
));
if let Some(error) = engine.error {
output.push_str(&format!(" Error: {}\n", redact_log_line(&error)));
}
}
if let Ok(settings) = crate::settings::load_settings(&app_handle) {
output.push_str(&format!(
"\nRuntime settings:\n- Max concurrent downloads: {}\n- Per-server connections: {}\n- Automatic retries: {}\n- Proxy mode: {:?}\n- Scheduler enabled: {}\n\n",
settings.max_concurrent_downloads,
settings.per_server_connections,
settings.max_automatic_retries,
settings.proxy_mode,
settings.scheduler.enabled,
));
}
for file in log_files(&app_handle).await? {
output.push_str(&format!("===== {} =====\n", file.display()));
let content = tokio::fs::read_to_string(&file)
.await
.map_err(|error| format!("failed to read '{}': {error}", file.display()))?;
for line in content.lines() {
output.push_str(&redact_log_line(line));
output.push('\n');
}
output.push('\n');
}
tokio::fs::write(&dest_path, output)
.await
.map_err(|e| e.to_string())?;
Ok(dest_path) Ok(dest_path)
} }
@@ -3154,7 +3308,7 @@ mod tests {
aggregate_media_fraction, build_media_format_options, collect_download_uris, aggregate_media_fraction, build_media_format_options, collect_download_uris,
is_excluded_yt_dlp_format, json_lower, media_progress_speed, is_excluded_yt_dlp_format, json_lower, media_progress_speed,
normalize_speed_limit_for_aria2, parse_firelink_urls, parse_media_progress_line, normalize_speed_limit_for_aria2, parse_firelink_urls, parse_media_progress_line,
MediaProgress, MEDIA_PROGRESS_PREFIX, redact_log_line, MediaProgress, MEDIA_PROGRESS_PREFIX,
}; };
use serde_json::json; use serde_json::json;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -3168,6 +3322,16 @@ mod tests {
assert_eq!(normalize_speed_limit_for_aria2("bad"), None); assert_eq!(normalize_speed_limit_for_aria2("bad"), None);
} }
#[test]
fn redacts_secrets_and_signed_url_queries_from_support_logs() {
let line = "Authorization: bearer-secret Cookie=session=abc https://example.com/file?token=secret";
let redacted = redact_log_line(line);
assert!(!redacted.contains("bearer-secret"));
assert!(!redacted.contains("session=abc"));
assert!(!redacted.contains("token=secret"));
assert!(redacted.contains("[redacted]"));
}
#[test] #[test]
fn collects_primary_url_and_unique_mirrors_in_order() { fn collects_primary_url_and_unique_mirrors_in_order() {
let uris = collect_download_uris( let uris = collect_download_uris(
@@ -3802,18 +3966,11 @@ pub fn run() {
.targets([ .targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: None }), tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: None }),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview),
]) ])
.level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info }) .level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info })
.max_file_size(10_000_000) .max_file_size(10_000_000)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3)) .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3))
.format(move |out, message, _record| { .timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal)
let msg = message.to_string();
if msg.contains("[download]") && msg.contains('%') {
return;
}
out.finish(format_args!("{}\n", msg));
})
.build(), .build(),
) )
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@@ -3833,6 +3990,7 @@ pub fn run() {
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, open_file, show_in_folder, get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, open_file, show_in_folder,
pause_download, resume_download, fetch_metadata, fetch_media_metadata, pause_download, resume_download, fetch_metadata, fetch_media_metadata,
update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action, update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action,
ack_schedule_trigger,
request_automation_permission, open_automation_settings, request_automation_permission, open_automation_settings,
set_keychain_password, get_keychain_password, delete_keychain_password, set_keychain_password, get_keychain_password, delete_keychain_password,
hydrate_extension_pairing_token, acknowledge_pairing_token_change, hydrate_extension_pairing_token, acknowledge_pairing_token_change,
@@ -3840,12 +3998,12 @@ pub fn run() {
get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download, get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
detach_download_for_reconfigure, detach_download_for_reconfigure,
enqueue_download, enqueue_many, move_in_queue, remove_from_queue, get_pending_order, enqueue_download, enqueue_many, move_in_queue, remove_from_queue, get_pending_order,
commands::reveal_in_file_manager, commands::open_downloaded_file, commands::trash_download_assets, commands::reveal_in_file_manager, commands::open_downloaded_file,
parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains, parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains,
parity::create_category_directories, parity::create_category_directories,
db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads, db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads,
db_get_all_queues, db_replace_queues, db_get_all_queues, db_replace_queues,
export_logs read_logs, export_logs
]) ])
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .expect("error while building tauri application")
+30 -23
View File
@@ -37,6 +37,7 @@ pub enum TaskKind {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct QueuedTask { pub struct QueuedTask {
pub id: String, pub id: String,
pub queue_id: String,
pub kind: TaskKind, pub kind: TaskKind,
pub payload: SpawnPayload, pub payload: SpawnPayload,
} }
@@ -158,11 +159,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
} }
/// Current pending order, as id list. Returned by move_in_queue. /// Current pending order, as id list. Returned by move_in_queue.
pub async fn pending_order(&self) -> Vec<String> { pub async fn pending_order(&self, queue_id: Option<&str>) -> Vec<String> {
self.pending self.pending
.lock() .lock()
.await .await
.iter() .iter()
.filter(|task| queue_id.is_none_or(|queue_id| task.queue_id == queue_id))
.map(|t| t.id.clone()) .map(|t| t.id.clone())
.collect() .collect()
} }
@@ -721,26 +723,38 @@ impl<R: tauri::Runtime> QueueManager<R> {
pub async fn move_in_queue( pub async fn move_in_queue(
&self, &self,
id: &str, id: &str,
queue_id: &str,
direction: QueueDirection, direction: QueueDirection,
) -> Vec<String> { ) -> Vec<String> {
let mut pending = self.pending.lock().await; let mut pending = self.pending.lock().await;
let pos = pending.iter().position(|t| t.id == id); let queue_positions = pending
if let Some(pos) = pos { .iter()
.enumerate()
.filter_map(|(index, task)| (task.queue_id == queue_id).then_some(index))
.collect::<Vec<_>>();
let queue_pos = queue_positions
.iter()
.position(|index| pending[*index].id == id);
if let Some(queue_pos) = queue_pos {
let target = match direction { let target = match direction {
QueueDirection::Up => pos.checked_sub(1), QueueDirection::Up => queue_pos.checked_sub(1),
QueueDirection::Down => { QueueDirection::Down => {
if pos + 1 < pending.len() { if queue_pos + 1 < queue_positions.len() {
Some(pos + 1) Some(queue_pos + 1)
} else { } else {
None None
} }
} }
}; };
if let Some(target) = target { if let Some(target) = target {
pending.swap(pos, target); pending.swap(queue_positions[queue_pos], queue_positions[target]);
} }
} }
pending.iter().map(|t| t.id.clone()).collect() pending
.iter()
.filter(|task| task.queue_id == queue_id)
.map(|task| task.id.clone())
.collect()
} }
/// Remove a task from pending if present (used by remove_download). /// Remove a task from pending if present (used by remove_download).
@@ -765,10 +779,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
for task in tasks { for task in tasks {
let id = task.id.clone(); let id = task.id.clone();
let filename = task.payload.filename.clone();
if registered.contains(&id) { if registered.contains(&id) {
results.push(crate::ipc::EnqueueResult { results.push(crate::ipc::EnqueueResult {
id: id.clone(), id: id.clone(),
success: false, success: false,
filename: None,
error: Some("Duplicate task".to_string()), error: Some("Duplicate task".to_string()),
}); });
continue; continue;
@@ -779,6 +795,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
results.push(crate::ipc::EnqueueResult { results.push(crate::ipc::EnqueueResult {
id, id,
success: true, success: true,
filename: Some(filename),
error: None, error: None,
}); });
} }
@@ -814,11 +831,7 @@ impl SidecarSpawner for ProductionSpawner {
"dir".to_string(), "dir".to_string(),
serde_json::json!(resolved_dest.to_string_lossy().to_string()), serde_json::json!(resolved_dest.to_string_lossy().to_string()),
); );
let safe_filename = std::path::Path::new(&payload.filename.replace('\\', "/")) let safe_filename = crate::download_ownership::canonical_download_filename(&payload.filename);
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
options.insert("out".to_string(), serde_json::json!(safe_filename)); options.insert("out".to_string(), serde_json::json!(safe_filename));
let conn = payload.connections.unwrap_or(1); let conn = payload.connections.unwrap_or(1);
options.insert("split".to_string(), serde_json::json!(conn.to_string())); options.insert("split".to_string(), serde_json::json!(conn.to_string()));
@@ -880,11 +893,7 @@ impl SidecarSpawner for ProductionSpawner {
log::warn!("aria2 addUri failed, falling back to native: {}", e); log::warn!("aria2 addUri failed, falling back to native: {}", e);
let download_id = uuid::Uuid::parse_str(id).map_err(|e| e.to_string())?; let download_id = uuid::Uuid::parse_str(id).map_err(|e| e.to_string())?;
let mt = payload.max_tries.unwrap_or(1).max(1) as u32; let mt = payload.max_tries.unwrap_or(1).max(1) as u32;
let safe_filename = std::path::Path::new(&payload.filename.replace('\\', "/")) let safe_filename = crate::download_ownership::canonical_download_filename(&payload.filename);
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
state state
.download_coordinator .download_coordinator
.send(crate::download::DownloadCmd::Start(Box::new( .send(crate::download::DownloadCmd::Start(Box::new(
@@ -969,11 +978,7 @@ impl SidecarSpawner for ProductionSpawner {
let download_id = uuid::Uuid::parse_str(id).map_err(|e| e.to_string())?; let download_id = uuid::Uuid::parse_str(id).map_err(|e| e.to_string())?;
let mt = payload.max_tries.unwrap_or(1).max(1) as u32; let mt = payload.max_tries.unwrap_or(1).max(1) as u32;
let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle); let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle);
let safe_filename = std::path::Path::new(&payload.filename.replace('\\', "/")) let safe_filename = crate::download_ownership::canonical_download_filename(&payload.filename);
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
let output_path = resolved_dest.join(safe_filename); let output_path = resolved_dest.join(safe_filename);
let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, &output_path); let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, &output_path);
state state
@@ -1002,6 +1007,7 @@ impl SidecarSpawner for ProductionSpawner {
#[ts(export, export_to = "../../src/bindings/")] #[ts(export, export_to = "../../src/bindings/")]
pub struct EnqueueItem { pub struct EnqueueItem {
pub id: String, pub id: String,
pub queue_id: String,
pub url: String, pub url: String,
pub destination: String, pub destination: String,
pub filename: String, pub filename: String,
@@ -1032,6 +1038,7 @@ impl EnqueueItem {
let id = self.id.clone(); let id = self.id.clone();
QueuedTask { QueuedTask {
id, id,
queue_id: self.queue_id,
kind, kind,
payload: SpawnPayload { payload: SpawnPayload {
url: self.url, url: self.url,
+51 -19
View File
@@ -1,10 +1,19 @@
use chrono::{Datelike, Local}; use chrono::{Datelike, Local, Timelike};
use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use tauri::Emitter; use tauri::Emitter;
fn minute_of_day(value: &str) -> Option<u32> {
let (hour, minute) = value.split_once(':')?;
let hour = hour.parse::<u32>().ok()?;
let minute = minute.parse::<u32>().ok()?;
(hour < 24 && minute < 60).then_some(hour * 60 + minute)
}
pub fn spawn_scheduler(app_handle: tauri::AppHandle) { pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1)); let mut interval = tokio::time::interval(Duration::from_secs(1));
let mut last_emit: HashMap<&'static str, std::time::Instant> = HashMap::new();
loop { loop {
interval.tick().await; interval.tick().await;
@@ -15,7 +24,7 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
} }
let now = Local::now(); let now = Local::now();
let current_time = now.format("%H:%M").to_string(); let current_minute = now.hour() * 60 + now.minute();
let current_day = now.weekday().num_days_from_sunday(); let current_day = now.weekday().num_days_from_sunday();
let allowed_today = let allowed_today =
@@ -25,37 +34,60 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
} }
let date_key = now.format("%Y-%m-%d").to_string(); let date_key = now.format("%Y-%m-%d").to_string();
let trigger_key = format!("{}-{}", date_key, current_time); let start_key = format!("{date_key}-start");
let stop_key = format!("{date_key}-stop");
let start_minute = minute_of_day(&scheduler.start_time);
let stop_minute = minute_of_day(&scheduler.stop_time);
let before_stop = !scheduler.stop_time_enabled
|| stop_minute.is_some_and(|stop| current_minute < stop);
if scheduler.start_time == current_time if start_minute.is_some_and(|start| current_minute >= start)
&& settings.scheduler_last_start_key != trigger_key && before_stop
&& settings.scheduler_last_start_key != start_key
&& last_emit
.get("start")
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
{ {
let key = trigger_key.clone();
let _ = crate::settings::update_settings_state(&app_handle, |state| {
state.insert("schedulerLastStartKey".to_string(), serde_json::json!(key));
state.insert("schedulerRunning".to_string(), serde_json::json!(true));
});
let _ = app_handle.emit("schedule-trigger", serde_json::json!({ let _ = app_handle.emit("schedule-trigger", serde_json::json!({
"action": "start", "action": "start",
"key": key "key": start_key
})); }));
last_emit.insert("start", std::time::Instant::now());
} }
if scheduler.stop_time_enabled if scheduler.stop_time_enabled
&& scheduler.stop_time == current_time && stop_minute.is_some_and(|stop| current_minute >= stop)
&& settings.scheduler_last_stop_key != trigger_key && settings.scheduler_last_stop_key != stop_key
&& last_emit
.get("stop")
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
{ {
let key = trigger_key.clone();
let _ = crate::settings::update_settings_state(&app_handle, |state| {
state.insert("schedulerLastStopKey".to_string(), serde_json::json!(key));
state.insert("schedulerRunning".to_string(), serde_json::json!(false));
});
let _ = app_handle.emit("schedule-trigger", serde_json::json!({ let _ = app_handle.emit("schedule-trigger", serde_json::json!({
"action": "stop", "action": "stop",
"key": key "key": stop_key
})); }));
last_emit.insert("stop", std::time::Instant::now());
} }
} }
} }
}); });
} }
#[cfg(test)]
mod tests {
use super::minute_of_day;
#[test]
fn parses_valid_scheduler_times() {
assert_eq!(minute_of_day("00:00"), Some(0));
assert_eq!(minute_of_day("23:59"), Some(1439));
assert_eq!(minute_of_day("06:30"), Some(390));
}
#[test]
fn rejects_invalid_scheduler_times() {
assert_eq!(minute_of_day("24:00"), None);
assert_eq!(minute_of_day("12:60"), None);
assert_eq!(minute_of_day("bad"), None);
}
}
+5
View File
@@ -243,6 +243,7 @@ fn default_settings() -> PersistedSettings {
stop_time: "08:00".to_string(), stop_time: "08:00".to_string(),
everyday: true, everyday: true,
selected_days: vec![0, 1, 2, 3, 4, 5, 6], selected_days: vec![0, 1, 2, 3, 4, 5, 6],
selected_queue_ids: vec!["00000000-0000-0000-0000-000000000001".to_string()],
post_queue_action: PostQueueAction::None, post_queue_action: PostQueueAction::None,
}, },
scheduler_last_start_key: String::new(), scheduler_last_start_key: String::new(),
@@ -299,6 +300,10 @@ mod tests {
assert!(settings.scheduler.enabled); assert!(settings.scheduler.enabled);
assert_eq!(settings.scheduler.start_time, "06:30"); assert_eq!(settings.scheduler.start_time, "06:30");
assert_eq!(settings.scheduler.selected_days, vec![1, 3, 5]); assert_eq!(settings.scheduler.selected_days, vec![1, 3, 5]);
assert_eq!(
settings.scheduler.selected_queue_ids,
vec!["00000000-0000-0000-0000-000000000001"]
);
assert_eq!(settings.base_download_folder, "~/Downloads"); assert_eq!(settings.base_download_folder, "~/Downloads");
} }
+6 -1
View File
@@ -438,7 +438,12 @@ async fn cancel_removes_partial_file_without_terminal_success_event() {
.await .await
.unwrap(); .unwrap();
wait_for_progress(&mut events, id, 256 * 1024).await; wait_for_progress(&mut events, id, 256 * 1024).await;
coordinator.send(DownloadCmd::Cancel(id)).await.unwrap(); let (cancelled_tx, cancelled_rx) = tokio::sync::oneshot::channel();
coordinator
.send(DownloadCmd::CancelWithAck(id, cancelled_tx))
.await
.unwrap();
cancelled_rx.await.unwrap();
tokio::time::timeout(TEST_TIMEOUT, async { tokio::time::timeout(TEST_TIMEOUT, async {
while output_path.exists() { while output_path.exists() {
+49 -24
View File
@@ -58,6 +58,7 @@ fn make_manager(capacity: usize) -> (QueueManager<tauri::test::MockRuntime>, Arc
fn sample_task(id: &str) -> QueuedTask { fn sample_task(id: &str) -> QueuedTask {
QueuedTask { QueuedTask {
id: id.to_string(), id: id.to_string(),
queue_id: "main".to_string(),
kind: TaskKind::Native, kind: TaskKind::Native,
payload: SpawnPayload::default(), payload: SpawnPayload::default(),
} }
@@ -66,9 +67,9 @@ fn sample_task(id: &str) -> QueuedTask {
#[tokio::test] #[tokio::test]
async fn push_appends_to_pending_and_emits_queued() { async fn push_appends_to_pending_and_emits_queued() {
let (mgr, _spawner) = make_manager(2); let (mgr, _spawner) = make_manager(2);
mgr.push(sample_task("a")).await; mgr.push(sample_task("a")).await.unwrap();
mgr.push(sample_task("b")).await; mgr.push(sample_task("b")).await.unwrap();
let order = mgr.pending_order().await; let order = mgr.pending_order(None).await;
assert_eq!(order, vec!["a".to_string(), "b".to_string()]); assert_eq!(order, vec!["a".to_string(), "b".to_string()]);
} }
@@ -116,8 +117,8 @@ async fn forgetting_aria2_gid_clears_mapping_without_releasing_twice() {
#[tokio::test] #[tokio::test]
async fn push_then_pop_front_drains_fifo() { async fn push_then_pop_front_drains_fifo() {
let (mgr, _spawner) = make_manager(2); let (mgr, _spawner) = make_manager(2);
mgr.push(sample_task("a")).await; mgr.push(sample_task("a")).await.unwrap();
mgr.push(sample_task("b")).await; mgr.push(sample_task("b")).await.unwrap();
let first = mgr.pop_front().await.expect("some task"); let first = mgr.pop_front().await.expect("some task");
let second = mgr.pop_front().await.expect("some task"); let second = mgr.pop_front().await.expect("some task");
assert_eq!(first.id, "a"); assert_eq!(first.id, "a");
@@ -179,7 +180,7 @@ async fn grow_releases_immediately_and_dispatches_waiting_tasks() {
let mgr_arc = Arc::new(mgr); let mgr_arc = Arc::new(mgr);
for i in 0..4 { for i in 0..4 {
mgr_arc.push(sample_task(&format!("t{i}"))).await; mgr_arc.push(sample_task(&format!("t{i}"))).await.unwrap();
} }
let handle = { let handle = {
let mgr_clone = Arc::clone(&mgr_arc); let mgr_clone = Arc::clone(&mgr_arc);
@@ -206,7 +207,7 @@ async fn shrink_converges_to_target_without_killing_active() {
let mgr_arc = Arc::new(mgr); let mgr_arc = Arc::new(mgr);
for i in 0..6 { for i in 0..6 {
mgr_arc.push(sample_task(&format!("t{i}"))).await; mgr_arc.push(sample_task(&format!("t{i}"))).await.unwrap();
} }
let handle = { let handle = {
let mgr_clone = Arc::clone(&mgr_arc); let mgr_clone = Arc::clone(&mgr_arc);
@@ -239,6 +240,7 @@ async fn shrink_converges_to_target_without_killing_active() {
fn aria2_task(id: &str) -> QueuedTask { fn aria2_task(id: &str) -> QueuedTask {
QueuedTask { QueuedTask {
id: id.to_string(), id: id.to_string(),
queue_id: "main".to_string(),
kind: TaskKind::Aria2, kind: TaskKind::Aria2,
payload: SpawnPayload::default(), payload: SpawnPayload::default(),
} }
@@ -247,6 +249,7 @@ fn aria2_task(id: &str) -> QueuedTask {
fn media_task(id: &str) -> QueuedTask { fn media_task(id: &str) -> QueuedTask {
QueuedTask { QueuedTask {
id: id.to_string(), id: id.to_string(),
queue_id: "main".to_string(),
kind: TaskKind::Media, kind: TaskKind::Media,
payload: SpawnPayload::default(), payload: SpawnPayload::default(),
} }
@@ -305,7 +308,7 @@ fn emitted_statuses(event_rx: &std::sync::mpsc::Receiver<String>) -> Vec<String>
async fn media_terminal_error_emits_failed_without_completed() { async fn media_terminal_error_emits_failed_without_completed() {
let (manager, event_rx) = make_media_manager(Err("terminal media failure".to_string())); let (manager, event_rx) = make_media_manager(Err("terminal media failure".to_string()));
let manager = Arc::new(manager); let manager = Arc::new(manager);
manager.push(media_task("media-failed")).await; manager.push(media_task("media-failed")).await.unwrap();
let dispatcher = { let dispatcher = {
let manager = Arc::clone(&manager); let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await }) tokio::spawn(async move { manager.run_dispatcher().await })
@@ -325,7 +328,7 @@ async fn media_cancellation_does_not_emit_completed() {
let (manager, event_rx) = let (manager, event_rx) =
make_media_manager(Err(MEDIA_RUN_CANCELLED.to_string())); make_media_manager(Err(MEDIA_RUN_CANCELLED.to_string()));
let manager = Arc::new(manager); let manager = Arc::new(manager);
manager.push(media_task("media-cancelled")).await; manager.push(media_task("media-cancelled")).await.unwrap();
let dispatcher = { let dispatcher = {
let manager = Arc::clone(&manager); let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await }) tokio::spawn(async move { manager.run_dispatcher().await })
@@ -344,7 +347,7 @@ async fn media_cancellation_does_not_emit_completed() {
async fn aria2_permit_survives_rpc_return() { async fn aria2_permit_survives_rpc_return() {
let (mgr, spawner) = make_manager(1); let (mgr, spawner) = make_manager(1);
let mgr_arc = Arc::new(mgr); let mgr_arc = Arc::new(mgr);
mgr_arc.push(aria2_task("a")).await; mgr_arc.push(aria2_task("a")).await.unwrap();
let handle = { let handle = {
let mgr_clone = Arc::clone(&mgr_arc); let mgr_clone = Arc::clone(&mgr_arc);
tokio::spawn(async move { mgr_clone.run_dispatcher().await }) tokio::spawn(async move { mgr_clone.run_dispatcher().await })
@@ -373,7 +376,7 @@ async fn gid_completion_before_store_buffers_and_reconciles() {
let (mgr, _spawner) = make_manager(1); let (mgr, _spawner) = make_manager(1);
let mgr_arc = Arc::new(mgr); let mgr_arc = Arc::new(mgr);
mgr_arc.push(aria2_task("a")).await; mgr_arc.push(aria2_task("a")).await.unwrap();
let handle = { let handle = {
let mgr_clone = Arc::clone(&mgr_arc); let mgr_clone = Arc::clone(&mgr_arc);
tokio::spawn(async move { mgr_clone.run_dispatcher().await }) tokio::spawn(async move { mgr_clone.run_dispatcher().await })
@@ -396,7 +399,7 @@ async fn gid_completion_before_store_buffers_and_reconciles() {
assert_eq!(mgr_arc.available_permits(), 1); assert_eq!(mgr_arc.available_permits(), 1);
// Push another aria2 task; its gid will be "gid-2". // Push another aria2 task; its gid will be "gid-2".
mgr_arc.push(aria2_task("b")).await; mgr_arc.push(aria2_task("b")).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
mgr_arc.release_permit("b").await; mgr_arc.release_permit("b").await;
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
@@ -425,21 +428,43 @@ async fn move_up_down_reorders_pending() {
let (mgr, _spawner) = make_manager(3); let (mgr, _spawner) = make_manager(3);
let mgr_arc = Arc::new(mgr); let mgr_arc = Arc::new(mgr);
mgr_arc.push(sample_task("a")).await; mgr_arc.push(sample_task("a")).await.unwrap();
mgr_arc.push(sample_task("b")).await; mgr_arc.push(sample_task("b")).await.unwrap();
mgr_arc.push(sample_task("c")).await; mgr_arc.push(sample_task("c")).await.unwrap();
mgr_arc.move_in_queue("c", QueueDirection::Down).await; mgr_arc.move_in_queue("c", "main", QueueDirection::Down).await;
assert_eq!(mgr_arc.pending_order().await, vec!["a", "b", "c"]); assert_eq!(mgr_arc.pending_order(None).await, vec!["a", "b", "c"]);
mgr_arc.move_in_queue("c", QueueDirection::Up).await; mgr_arc.move_in_queue("c", "main", QueueDirection::Up).await;
assert_eq!(mgr_arc.pending_order().await, vec!["a", "c", "b"]); assert_eq!(mgr_arc.pending_order(None).await, vec!["a", "c", "b"]);
mgr_arc.move_in_queue("a", QueueDirection::Down).await; mgr_arc.move_in_queue("a", "main", QueueDirection::Down).await;
assert_eq!(mgr_arc.pending_order().await, vec!["c", "a", "b"]); assert_eq!(mgr_arc.pending_order(None).await, vec!["c", "a", "b"]);
mgr_arc.move_in_queue("c", QueueDirection::Up).await; mgr_arc.move_in_queue("c", "main", QueueDirection::Up).await;
assert_eq!(mgr_arc.pending_order().await, vec!["c", "a", "b"]); assert_eq!(mgr_arc.pending_order(None).await, vec!["c", "a", "b"]);
}
#[tokio::test]
async fn moving_one_queue_does_not_reorder_another_queue() {
use firelink_lib::ipc::QueueDirection;
let (mgr, _spawner) = make_manager(3);
let mut a1 = sample_task("a1");
a1.queue_id = "a".to_string();
let mut b1 = sample_task("b1");
b1.queue_id = "b".to_string();
let mut a2 = sample_task("a2");
a2.queue_id = "a".to_string();
let mut b2 = sample_task("b2");
b2.queue_id = "b".to_string();
mgr.push(a1).await.unwrap();
mgr.push(b1).await.unwrap();
mgr.push(a2).await.unwrap();
mgr.push(b2).await.unwrap();
assert_eq!(mgr.move_in_queue("a2", "a", QueueDirection::Up).await, vec!["a2", "a1"]);
assert_eq!(mgr.pending_order(Some("b")).await, vec!["b1", "b2"]);
} }
#[tokio::test] #[tokio::test]
@@ -455,7 +480,7 @@ async fn notify_fires_on_push_and_release() {
tokio::spawn(async move { mgr_clone.run_dispatcher().await }) tokio::spawn(async move { mgr_clone.run_dispatcher().await })
}; };
mgr_arc.push(sample_task("x")).await; mgr_arc.push(sample_task("x")).await.unwrap();
let dispatched = timeout( let dispatched = timeout(
Duration::from_millis(150), Duration::from_millis(150),
async { async {
+53 -15
View File
@@ -14,11 +14,20 @@ import { useSettingsStore } from "./store/useSettingsStore";
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
import SchedulerView from "./components/SchedulerView"; import SchedulerView from "./components/SchedulerView";
import SpeedLimiterView from "./components/SpeedLimiterView"; import SpeedLimiterView from "./components/SpeedLimiterView";
import DiagnosticsView from "./components/DiagnosticsView"; import LogsView from "./components/LogsView";
import { useToast } from "./contexts/ToastContext"; import { useToast } from "./contexts/ToastContext";
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '@tauri-apps/plugin-opener';
let automaticUpdateCheckStarted = false; let automaticUpdateCheckStarted = false;
const processingScheduleKeys = new Set<string>();
const getScheduledQueueIds = () => {
const downloadState = useDownloadStore.getState();
const availableQueueIds = new Set(downloadState.queues.map(queue => queue.id));
const selectedQueueIds = useSettingsStore.getState().scheduler.selectedQueueIds
.filter(queueId => availableQueueIds.has(queueId));
return selectedQueueIds.length > 0 ? selectedQueueIds : [MAIN_QUEUE_ID];
};
function App() { function App() {
const [filter, setFilter] = useState<SidebarFilter>('all'); const [filter, setFilter] = useState<SidebarFilter>('all');
@@ -40,9 +49,12 @@ function App() {
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken); const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
const downloads = useDownloadStore(state => state.downloads); const downloads = useDownloadStore(state => state.downloads);
const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length; const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length;
const queuedCount = downloads.filter(download => download.status === 'queued').length; const queuedCount = downloads.filter(download =>
download.status === 'queued' || download.status === 'staged'
).length;
const doneCount = downloads.filter(download => download.status === 'completed').length; const doneCount = downloads.filter(download => download.status === 'completed').length;
const schedulerRunning = useSettingsStore(state => state.schedulerRunning); const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit); const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const previousSpeedLimit = useRef<string | null>(null); const previousSpeedLimit = useRef<string | null>(null);
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
@@ -218,15 +230,28 @@ function App() {
useEffect(() => { useEffect(() => {
const unlisten = listen('schedule-trigger', async (event) => { const unlisten = listen('schedule-trigger', async (event) => {
const state = useSettingsStore.getState(); const state = useSettingsStore.getState();
const payload = event.payload as any; const payload = event.payload;
if (payload.action === 'start') { if (processingScheduleKeys.has(payload.key)) return;
state.setSchedulerLastStartKey(payload.key); processingScheduleKeys.add(payload.key);
const started = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID); try {
state.setSchedulerRunning(started > 0); if (payload.action === 'start') {
} else if (payload.action === 'stop') { const startedResults = await Promise.all(
state.setSchedulerLastStopKey(payload.key); getScheduledQueueIds().map(queueId => useDownloadStore.getState().startQueue(queueId))
await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID); );
state.setSchedulerRunning(false); const acceptedIds = startedResults.flat();
state.setSchedulerActiveDownloadIds(acceptedIds);
state.setSchedulerRunning(acceptedIds.length > 0);
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
} else if (payload.action === 'stop') {
await Promise.all(
getScheduledQueueIds().map(queueId => useDownloadStore.getState().pauseQueue(queueId))
);
state.setSchedulerActiveDownloadIds([]);
state.setSchedulerRunning(false);
await invoke('ack_schedule_trigger', { action: 'stop', key: payload.key });
}
} finally {
processingScheduleKeys.delete(payload.key);
} }
}); });
@@ -237,19 +262,32 @@ function App() {
useEffect(() => { useEffect(() => {
if (!schedulerRunning) return; if (!schedulerRunning) return;
if (schedulerActiveDownloadIds.length === 0) return;
const scheduledIds = new Set(schedulerActiveDownloadIds);
const hasPendingScheduledWork = downloads.some(download => const hasPendingScheduledWork = downloads.some(download =>
isActiveDownloadStatus(download.status) scheduledIds.has(download.id) && isActiveDownloadStatus(download.status)
); );
if (hasPendingScheduledWork) return; if (hasPendingScheduledWork) return;
const settings = useSettingsStore.getState(); const settings = useSettingsStore.getState();
const scheduledItems = schedulerActiveDownloadIds.map(id =>
downloads.find(download => download.id === id)
);
const hasFailures = scheduledItems.some(item => !item || item.status === 'failed');
settings.setSchedulerActiveDownloadIds([]);
settings.setSchedulerRunning(false); settings.setSchedulerRunning(false);
if (settings.scheduler.postQueueAction !== 'none') { if (hasFailures) {
addToast({
message: 'Scheduled downloads finished with failures. The post-queue system action was skipped.',
variant: 'warning',
isActionable: true
});
} else if (settings.scheduler.postQueueAction !== 'none') {
invoke('perform_system_action', { action: settings.scheduler.postQueueAction }).catch(error => { invoke('perform_system_action', { action: settings.scheduler.postQueueAction }).catch(error => {
console.error('Scheduled post action failed:', error); console.error('Scheduled post action failed:', error);
}); });
} }
}, [downloads, schedulerRunning]); }, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]);
useEffect(() => { useEffect(() => {
const initNotifications = async () => { const initNotifications = async () => {
@@ -395,7 +433,7 @@ function App() {
{activeView === 'settings' && <SettingsView />} {activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />} {activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />} {activeView === 'speedLimiter' && <SpeedLimiterView />}
{activeView === 'diagnostics' && <DiagnosticsView />} {activeView === 'logs' && <LogsView />}
</div> </div>
{/* Status Bar */} {/* Status Bar */}
+1 -1
View File
@@ -33,7 +33,7 @@ export class ErrorBoundary extends Component<Props, State> {
<div className="app-card max-w-lg space-y-4 p-6 text-center"> <div className="app-card max-w-lg space-y-4 p-6 text-center">
<h1 className="text-xl font-semibold">Firelink could not display this window.</h1> <h1 className="text-xl font-semibold">Firelink could not display this window.</h1>
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
The error was written to Diagnostics. Reload the interface to reconnect to the running download service. The error was written to Logs. Reload the interface to reconnect to the running download service.
</p> </p>
<button <button
type="button" type="button"
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ActiveView = "downloads" | "settings" | "scheduler" | "speedLimiter" | "diagnostics"; export type ActiveView = "downloads" | "settings" | "scheduler" | "speedLimiter" | "logs";
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus"; import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, hasBeenDispatched?: boolean, }; export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadStatus = "ready" | "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying"; export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying";
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type EnqueueAccepted = { id: string, filename: string, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type EnqueueItem = { id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, }; export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type EnqueueResult = { id: string, success: boolean, error?: string, }; export type EnqueueResult = { id: string, success: boolean, filename?: string, error?: string, };
+1 -1
View File
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PostQueueAction } from "./PostQueueAction"; import type { PostQueueAction } from "./PostQueueAction";
export type SchedulerSettings = { enabled: boolean, startTime: string, stopTimeEnabled: boolean, stopTime: string, everyday: boolean, selectedDays: Array<number>, postQueueAction: PostQueueAction, }; export type SchedulerSettings = { enabled: boolean, startTime: string, stopTimeEnabled: boolean, stopTime: string, everyday: boolean, selectedDays: Array<number>, selectedQueueIds: Array<string>, postQueueAction: PostQueueAction, };
+108 -44
View File
@@ -9,7 +9,7 @@ import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database,
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc'; import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads'; import { canonicalizeDownloadFileName, categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata'; import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
import { import {
resolveCategoryDestination, resolveCategoryDestination,
@@ -72,8 +72,10 @@ export const AddDownloadsModal = () => {
const [showingDuplicates, setShowingDuplicates] = useState(false); const [showingDuplicates, setShowingDuplicates] = useState(false);
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' }); const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false); const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<number, string>>({});
const [resolvedLocation, setResolvedLocation] = useState(''); const [resolvedLocation, setResolvedLocation] = useState('');
const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false); const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const actionMenuRef = useRef<HTMLDivElement>(null); const actionMenuRef = useRef<HTMLDivElement>(null);
// Right Form // Right Form
@@ -104,7 +106,10 @@ export const AddDownloadsModal = () => {
setParsedItems([]); setParsedItems([]);
setSelectedItemIndex(null); setSelectedItemIndex(null);
setPendingUseSharedDestination(false); setPendingUseSharedDestination(false);
setPendingDestinationOverrides({});
setConnections(perServerConnections); setConnections(perServerConnections);
setSpeedLimitEnabled(false);
setSpeedLimit('1024');
setUseAuth(false); setUseAuth(false);
setUsername(''); setUsername('');
setPassword(''); setPassword('');
@@ -119,6 +124,7 @@ export const AddDownloadsModal = () => {
setCookies(pendingAddCookies); setCookies(pendingAddCookies);
setMirrors(''); setMirrors('');
setIsQueueMenuOpen(false); setIsQueueMenuOpen(false);
setIsSubmitting(false);
} else { } else {
setUrls(''); setUrls('');
} }
@@ -211,8 +217,8 @@ export const AddDownloadsModal = () => {
const mediaData = await fetchMediaMetadataDeduped({ const mediaData = await fetchMediaMetadataDeduped({
url, url,
cookieBrowser: browserArg, cookieBrowser: browserArg,
username: login?.username || null, username: useAuth ? username.trim() || null : login?.username || null,
password: keychainPassword password: useAuth ? password || null : keychainPassword
}); });
if (mediaData && mediaData.formats.length > 0) { if (mediaData && mediaData.formats.length > 0) {
const mappedFormats = mediaData.formats.map(f => { const mappedFormats = mediaData.formats.map(f => {
@@ -235,7 +241,7 @@ export const AddDownloadsModal = () => {
}); });
updatedItems[i] = { updatedItems[i] = {
url, url,
file: `${mediaData.title}.${mediaData.formats[0].ext}`, file: canonicalizeDownloadFileName(`${mediaData.title}.${mediaData.formats[0].ext}`),
size: mappedFormats[0].detail, size: mappedFormats[0].detail,
sizeBytes: mappedFormats[0].bytes, sizeBytes: mappedFormats[0].bytes,
status: 'Ready', status: 'Ready',
@@ -260,12 +266,14 @@ export const AddDownloadsModal = () => {
const meta = await invoke('fetch_metadata', { const meta = await invoke('fetch_metadata', {
url, url,
userAgent: settingsStore.customUserAgent || null, userAgent: settingsStore.customUserAgent || null,
username: login?.username || null, username: useAuth ? username.trim() || null : login?.username || null,
password: keychainPassword password: useAuth ? password || null : keychainPassword
}); });
updatedItems[i] = { updatedItems[i] = {
url: meta.url || url, url: meta.url || url,
file: lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename, file: canonicalizeDownloadFileName(
lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename
),
size: meta.size, size: meta.size,
sizeBytes: meta.size_bytes, sizeBytes: meta.size_bytes,
status: 'Ready' status: 'Ready'
@@ -301,7 +309,15 @@ export const AddDownloadsModal = () => {
active = false; active = false;
clearTimeout(timer); clearTimeout(timer);
}; };
}, [urls, pendingAddFilename, isSaveLocationManual, metadataRefreshNonce]); }, [
urls,
pendingAddFilename,
isSaveLocationManual,
metadataRefreshNonce,
useAuth,
username,
password
]);
if (!isAddModalOpen) return null; if (!isAddModalOpen) return null;
@@ -327,25 +343,41 @@ export const AddDownloadsModal = () => {
}; };
const handleAction = async (action: AddDownloadAction) => { const handleAction = async (action: AddDownloadAction) => {
if (isSubmitting || parsedItems.length === 0 || parsedItems.some(item => item.status !== 'Ready')) {
return;
}
if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) {
addToast({ message: 'Speed limit must be greater than zero', variant: 'error', isActionable: true });
return;
}
setIsSubmitting(true);
let finalLocation = saveLocation; let finalLocation = saveLocation;
let useSharedDestination = isSaveLocationManual; let useSharedDestination = isSaveLocationManual;
const destinationOverrides: Record<number, string> = {};
const settings = useSettingsStore.getState(); const settings = useSettingsStore.getState();
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) { if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
try { for (const [index, item] of parsedItems.entries()) {
const selected = await open({ try {
directory: true, const suggestedLocation = isSaveLocationManual
multiple: false, ? finalLocation
defaultPath: finalLocation.startsWith('~') ? undefined : finalLocation : await categoryLocationForFile(item.file);
}); const selected = await open({
if (selected && typeof selected === 'string') { directory: true,
finalLocation = selected; multiple: false,
useSharedDestination = true; title: `Choose a folder for ${item.file}`,
setIsSaveLocationManual(true); defaultPath: suggestedLocation.startsWith('~') ? undefined : suggestedLocation
} else { });
return; // Cancelled if (selected && typeof selected === 'string') {
destinationOverrides[index] = selected;
} else {
setIsSubmitting(false);
return;
}
} catch (e) {
console.error("Failed to select folder:", e);
setIsSubmitting(false);
return;
} }
} catch (e) {
console.error("Failed to select folder:", e);
} }
} }
@@ -355,7 +387,7 @@ export const AddDownloadsModal = () => {
for (let i = 0; i < parsedItems.length; i++) { for (let i = 0; i < parsedItems.length; i++) {
const item = parsedItems[i]; const item = parsedItems[i];
let finalFile = item.file; let finalFile = canonicalizeDownloadFileName(item.file);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) { if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat]; const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -363,7 +395,7 @@ export const AddDownloadsModal = () => {
} }
const itemLocation = useSharedDestination const itemLocation = useSharedDestination
? finalLocation ? finalLocation
: await categoryLocationForFile(finalFile); : destinationOverrides[i] || await categoryLocationForFile(finalFile);
const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed'); const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed');
if (isUrlDupe) { if (isUrlDupe) {
@@ -400,14 +432,26 @@ export const AddDownloadsModal = () => {
setConflicts(newConflicts); setConflicts(newConflicts);
setPendingAction(action); setPendingAction(action);
setPendingUseSharedDestination(useSharedDestination); setPendingUseSharedDestination(useSharedDestination);
setPendingDestinationOverrides(destinationOverrides);
setShowingDuplicates(true); setShowingDuplicates(true);
setIsSubmitting(false);
return; return;
} }
await executeAddDownloads(action, finalLocation, useSharedDestination); try {
await executeAddDownloads(action, finalLocation, useSharedDestination, undefined, destinationOverrides);
} finally {
setIsSubmitting(false);
}
}; };
const executeAddDownloads = async (action: AddDownloadAction, finalLocation: string, useSharedDestination: boolean, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => { const executeAddDownloads = async (
action: AddDownloadAction,
finalLocation: string,
useSharedDestination: boolean,
resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[],
destinationOverrides: Record<number, string> = {}
) => {
let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems]; let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems];
if (resolutions) { if (resolutions) {
@@ -420,7 +464,7 @@ export const AddDownloadsModal = () => {
if (res.resolution === 'skip') { if (res.resolution === 'skip') {
itemsToAdd[idx] = null; itemsToAdd[idx] = null;
} else if (res.resolution === 'rename') { } else if (res.resolution === 'rename') {
let finalFile = item.file; let finalFile = canonicalizeDownloadFileName(item.file);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) { if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat]; const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -428,7 +472,7 @@ export const AddDownloadsModal = () => {
} }
const itemLocation = useSharedDestination const itemLocation = useSharedDestination
? finalLocation ? finalLocation
: await categoryLocationForFile(finalFile); : destinationOverrides[idx] || await categoryLocationForFile(finalFile);
let count = 1; let count = 1;
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -471,7 +515,7 @@ export const AddDownloadsModal = () => {
itemsToAdd[idx] = null; itemsToAdd[idx] = null;
continue; continue;
} }
let finalFile = item.file; let finalFile = canonicalizeDownloadFileName(item.file);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) { if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat]; const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -479,7 +523,7 @@ export const AddDownloadsModal = () => {
} }
const itemLocation = useSharedDestination const itemLocation = useSharedDestination
? finalLocation ? finalLocation
: await categoryLocationForFile(finalFile); : destinationOverrides[idx] || await categoryLocationForFile(finalFile);
const fullPath = await resolveDownloadFilePath(itemLocation, finalFile); const fullPath = await resolveDownloadFilePath(itemLocation, finalFile);
const store = useDownloadStore.getState(); const store = useDownloadStore.getState();
@@ -510,14 +554,14 @@ export const AddDownloadsModal = () => {
} }
} }
const resolvedItems = itemsToAdd.filter((item): item is ParsedDownloadItem => item !== null);
let addedCount = 0; let addedCount = 0;
const failures: string[] = []; const failures: string[] = [];
for (const item of resolvedItems) { for (const [itemIndex, item] of itemsToAdd.entries()) {
if (!item) continue;
try { try {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
let finalFile = item.file; let finalFile = canonicalizeDownloadFileName(item.file);
let formatSelector = undefined; let formatSelector = undefined;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) { if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
@@ -546,7 +590,9 @@ export const AddDownloadsModal = () => {
: undefined, : undefined,
cookies: cookies.trim() || undefined, cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined, mirrors: mirrors.trim() || undefined,
destination: useSharedDestination ? finalLocation : undefined, destination: useSharedDestination
? finalLocation
: destinationOverrides[itemIndex],
isMedia: item.isMedia, isMedia: item.isMedia,
mediaFormatSelector: formatSelector, mediaFormatSelector: formatSelector,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined) size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
@@ -598,16 +644,20 @@ export const AddDownloadsModal = () => {
selectedItem.size = format.detail || 'Unknown'; selectedItem.size = format.detail || 'Unknown';
selectedItem.sizeBytes = format.bytes || 0; selectedItem.sizeBytes = format.bytes || 0;
const baseName = selectedItem.file.substring(0, selectedItem.file.lastIndexOf('.')) || selectedItem.file; const baseName = selectedItem.file.substring(0, selectedItem.file.lastIndexOf('.')) || selectedItem.file;
selectedItem.file = `${baseName}.${format.ext}`; selectedItem.file = canonicalizeDownloadFileName(`${baseName}.${format.ext}`);
setParsedItems(newItems); setParsedItems(newItems);
}; };
const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0); const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
const hasApproximateSize = parsedItems.some(item =>
item.formats?.[item.selectedFormat ?? -1]?.isApproximate
);
const requiredStr = requiredBytes > 0 const requiredStr = requiredBytes > 0
? (requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB` ? `${hasApproximateSize ? '~' : ''}${requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB`
: requiredBytes < 1024 * 1024 * 1024 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB` : requiredBytes < 1024 * 1024 * 1024 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB`
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`) : `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}`
: 'Unknown'; : 'Unknown';
const canSubmit = parsedItems.length > 0 && parsedItems.every(item => item.status === 'Ready');
return ( return (
<> <>
@@ -616,14 +666,22 @@ export const AddDownloadsModal = () => {
conflicts={conflicts} conflicts={conflicts}
onConfirm={(resolutions) => { onConfirm={(resolutions) => {
setShowingDuplicates(false); setShowingDuplicates(false);
void executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions) setIsSubmitting(true);
void executeAddDownloads(
pendingAction,
resolvedLocation,
pendingUseSharedDestination,
resolutions,
pendingDestinationOverrides
)
.catch(error => { .catch(error => {
addToast({ addToast({
message: `Could not resolve duplicate downloads: ${String(error)}`, message: `Could not resolve duplicate downloads: ${String(error)}`,
variant: 'error', variant: 'error',
isActionable: true isActionable: true
}); });
}); })
.finally(() => setIsSubmitting(false));
}} }}
onCancel={() => setShowingDuplicates(false)} onCancel={() => setShowingDuplicates(false)}
/> />
@@ -652,7 +710,9 @@ export const AddDownloadsModal = () => {
onChange={(e) => setUrls(e.target.value)} onChange={(e) => setUrls(e.target.value)}
/> />
<div className="flex justify-between items-center px-1"> <div className="flex justify-between items-center px-1">
<span className="text-[11px] text-text-muted font-medium">{parsedItems.length} valid link(s) detected</span> <span className="text-[11px] text-text-muted font-medium">
{parsedItems.filter(item => item.status === 'Ready').length} ready, {parsedItems.filter(item => item.status === 'Error').length} failed
</span>
<button <button
type="button" type="button"
onClick={() => setMetadataRefreshNonce(value => value + 1)} onClick={() => setMetadataRefreshNonce(value => value + 1)}
@@ -836,7 +896,7 @@ export const AddDownloadsModal = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs text-text-secondary font-medium">Connections per File</label> <label className="text-xs text-text-secondary font-medium">Connections per File</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="add-download-range w-24 accent-blue-500 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50" disabled={parsedItems.some(i => i.isMedia)} aria-label="Connections per file" /> <input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="add-download-range w-24 accent-blue-500 cursor-pointer" aria-label="Connections per file" />
<span className="add-download-value text-xs text-text-primary font-mono w-6 text-center">{connections}</span> <span className="add-download-value text-xs text-text-primary font-mono w-6 text-center">{connections}</span>
</div> </div>
</div> </div>
@@ -924,7 +984,11 @@ export const AddDownloadsModal = () => {
{/* Footer */} {/* Footer */}
<div className="add-download-footer p-4 flex items-center shrink-0"> <div className="add-download-footer p-4 flex items-center shrink-0">
<div className="text-[11px] text-text-muted font-medium flex-1"> <div className="text-[11px] text-text-muted font-medium flex-1">
{parsedItems.length === 0 ? "Paste one or more links." : `Ready to add ${parsedItems.length} download(s).`} {parsedItems.length === 0
? 'Paste one or more links.'
: canSubmit
? `Ready to add ${parsedItems.length} download(s).`
: 'Wait for metadata or remove links that failed validation.'}
</div> </div>
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<button onClick={() => toggleAddModal(false)} className="add-download-button add-download-button-cancel px-4 text-xs"> <button onClick={() => toggleAddModal(false)} className="add-download-button add-download-button-cancel px-4 text-xs">
@@ -933,7 +997,7 @@ export const AddDownloadsModal = () => {
<div ref={actionMenuRef} className="relative flex gap-2.5"> <div ref={actionMenuRef} className="relative flex gap-2.5">
<button <button
onClick={() => handleAction({ type: 'start-now' })} onClick={() => handleAction({ type: 'start-now' })}
disabled={parsedItems.length === 0} disabled={!canSubmit || isSubmitting}
className="add-download-button add-download-button-primary px-5 text-xs" className="add-download-button add-download-button-primary px-5 text-xs"
> >
<Play size={12} fill="currentColor" /> Start Downloads <Play size={12} fill="currentColor" /> Start Downloads
@@ -942,7 +1006,7 @@ export const AddDownloadsModal = () => {
<button <button
type="button" type="button"
onClick={() => setIsQueueMenuOpen(open => !open)} onClick={() => setIsQueueMenuOpen(open => !open)}
disabled={parsedItems.length === 0} disabled={!canSubmit || isSubmitting}
className="add-download-button add-download-button-secondary px-4 text-xs" className="add-download-button add-download-button-secondary px-4 text-xs"
aria-label="Add to queue" aria-label="Add to queue"
aria-haspopup="menu" aria-haspopup="menu"
+24 -11
View File
@@ -31,9 +31,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
onClick, onClick,
}) => { }) => {
const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId)); const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId));
const pendingOrder = useDownloadStore(state => state.pendingOrder); const queueItems = useDownloadStore(state => {
const item = state.downloads.find(candidate => candidate.id === downloadId);
if (!item) return [];
const queueId = item.queueId;
return state.downloads
.filter(candidate =>
candidate.queueId === queueId &&
candidate.status !== 'completed'
)
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
.map(candidate => candidate.id);
});
const moveInQueue = useDownloadStore(state => state.moveInQueue); const moveInQueue = useDownloadStore(state => state.moveInQueue);
const queueIndex = pendingOrder.indexOf(downloadId); const queueIndex = queueItems.indexOf(downloadId);
const progressBarRef = useRef<HTMLDivElement>(null); const progressBarRef = useRef<HTMLDivElement>(null);
const statusTextRef = useRef<HTMLSpanElement>(null); const statusTextRef = useRef<HTMLSpanElement>(null);
@@ -106,7 +117,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
className={`download-progress-fill ${ className={`download-progress-fill ${
download.status === 'paused' ? 'paused' : download.status === 'paused' ? 'paused' :
download.status === 'processing' ? 'processing' : download.status === 'processing' ? 'processing' :
download.status === 'queued' ? 'queued' : download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : '' download.status === 'retrying' ? 'retrying' : ''
}`} }`}
style={{ width: `${(download.fraction || 0) * 100}%` }} style={{ width: `${(download.fraction || 0) * 100}%` }}
@@ -115,8 +126,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<span <span
ref={statusTextRef} ref={statusTextRef}
title={ title={
download.status === 'queued' && queueIndex !== -1 (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
? `Queued #${queueIndex + 1}` ? `${download.status === 'staged' ? 'In queue' : 'Queued'} #${queueIndex + 1}`
: download.status === 'downloading' : download.status === 'downloading'
? `${((download.fraction || 0) * 100).toFixed(0)}%` ? `${((download.fraction || 0) * 100).toFixed(0)}%`
: download.status === 'processing' : download.status === 'processing'
@@ -128,14 +139,16 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'failed' ? 'download-status-failed' : download.status === 'failed' ? 'download-status-failed' :
download.status === 'processing' ? 'download-status-processing' : download.status === 'processing' ? 'download-status-processing' :
download.status === 'downloading' ? 'download-status-downloading' : download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'queued' ? 'download-status-queued' : download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
download.status === 'retrying' ? 'download-status-retrying' : '' download.status === 'retrying' ? 'download-status-retrying' : ''
}`} }`}
> >
{download.status === 'queued' && queueIndex !== -1 ? ( {(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? (
<> <>
<Clock size={12} className="animate-pulse shrink-0" /> <Clock size={12} className={download.status === 'queued' ? 'animate-pulse shrink-0' : 'shrink-0'} />
<span className="truncate">Queued #{queueIndex + 1}</span> <span className="truncate">
{download.status === 'staged' ? 'In queue' : 'Queued'} #{queueIndex + 1}
</span>
</> </>
) : download.status === 'downloading' ? ( ) : download.status === 'downloading' ? (
`${((download.fraction || 0) * 100).toFixed(0)}%` `${((download.fraction || 0) * 100).toFixed(0)}%`
@@ -181,7 +194,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto" className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto"
onDoubleClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}
> >
{download.status === 'queued' && queueIndex !== -1 && ( {(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
<> <>
<button <button
onClick={() => moveInQueue(download.id, 'up')} onClick={() => moveInQueue(download.id, 'up')}
@@ -193,7 +206,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</button> </button>
<button <button
onClick={() => moveInQueue(download.id, 'down')} onClick={() => moveInQueue(download.id, 'down')}
disabled={queueIndex === pendingOrder.length - 1} disabled={queueIndex === queueItems.length - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40" className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Down" title="Move Down"
> >
+8 -13
View File
@@ -338,7 +338,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
</div> </div>
<div className="download-table-body"> <div className="download-table-body">
<div className="h-full overflow-auto flex flex-col"> <div className="download-table-list">
{filteredDownloads.map((d, index) => ( {filteredDownloads.map((d, index) => (
<DownloadItemComponent <DownloadItemComponent
key={d.id} key={d.id}
@@ -354,16 +354,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
onClick={handleItemClick} onClick={handleItemClick}
/> />
))} ))}
{Array.from({ length: Math.max(0, 50 - filteredDownloads.length) }).map((_, i) => { <div className="flex-1 min-h-0 bg-transparent pointer-events-none" />
const globalIndex = filteredDownloads.length + i;
return (
<div
key={`ghost-${i}`}
className={`download-ghost-row ${globalIndex % 2 !== 0 ? 'striped' : ''}`}
/>
);
})}
<div className="flex-1 bg-transparent pointer-events-none"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -411,7 +402,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{queues.map(q => ( {queues.map(q => (
<button key={q.id} onClick={() => { <button key={q.id} onClick={() => {
setContextMenu(null); setContextMenu(null);
assignToQueue(Array.from(selectedIds), q.id); void assignToQueue(Array.from(selectedIds), q.id).catch(error => {
showInteractionError('Could not move downloads to queue', error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]"> }} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name} {q.name}
</button> </button>
@@ -526,7 +519,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{queues.map(q => ( {queues.map(q => (
<button key={q.id} onClick={() => { <button key={q.id} onClick={() => {
setContextMenu(null); setContextMenu(null);
assignToQueue([contextItem.id], q.id); void assignToQueue([contextItem.id], q.id).catch(error => {
showInteractionError('Could not move download to queue', error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]"> }} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name} {q.name}
</button> </button>
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { attachLogger } from '@tauri-apps/plugin-log';
import { invokeCommand as invoke } from '../ipc'; import { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog'; import { save } from '@tauri-apps/plugin-dialog';
import { FileDown, Trash2, Terminal, Filter } from 'lucide-react'; import { FileDown, Trash2, Terminal, Filter } from 'lucide-react';
@@ -11,39 +10,46 @@ interface LogEntry {
message: string; message: string;
} }
const getLevelStr = (level: number): LogEntry['level'] => { export default function LogsView() {
switch (level) {
case 1: return 'Trace';
case 2: return 'Debug';
case 3: return 'Info';
case 4: return 'Warn';
case 5: return 'Error';
default: return 'Debug';
}
};
export default function DiagnosticsView() {
const { addToast } = useToast(); const { addToast } = useToast();
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All'); const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const rawLineCountRef = useRef(0);
const clearedThroughRef = useRef(0);
const lastSnapshotRef = useRef('');
const MAX_LOG_LINES = 2000; const MAX_LOG_LINES = 2000;
useEffect(() => { useEffect(() => {
let active = true; let active = true;
const unlistenPromise = attachLogger((logRecord) => { const refresh = async () => {
if (!active) return; try {
const level = getLevelStr(logRecord.level); const lines = await invoke('read_logs', { limit: MAX_LOG_LINES });
const message = logRecord.message; if (!active) return;
if (message.includes('[download]') && message.includes('%')) return; if (lines.length < clearedThroughRef.current) {
setLogs(prev => { clearedThroughRef.current = 0;
const next = [...prev, { level, message }]; }
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next; const snapshot = `${lines.length}:${lines[lines.length - 1] || ''}`;
}); if (snapshot === lastSnapshotRef.current) return;
}); lastSnapshotRef.current = snapshot;
rawLineCountRef.current = lines.length;
setLogs(lines.slice(clearedThroughRef.current).map(message => {
const level = message.includes('[ERROR]') ? 'Error'
: message.includes('[WARN]') ? 'Warn'
: message.includes('[INFO]') ? 'Info'
: message.includes('[TRACE]') ? 'Trace'
: 'Debug';
return { level, message };
}));
} catch {
if (active) setLogs([]);
}
};
void refresh();
const interval = window.setInterval(refresh, 2000);
return () => { return () => {
active = false; active = false;
void unlistenPromise.then(unlisten => unlisten()).catch(() => undefined); window.clearInterval(interval);
}; };
}, []); }, []);
@@ -56,19 +62,22 @@ export default function DiagnosticsView() {
const handleExport = async () => { const handleExport = async () => {
try { try {
const path = await save({ const path = await save({
defaultPath: 'Firelink-Diagnostics.log', defaultPath: 'Firelink-Support-Logs.log',
filters: [{ name: 'Log Files', extensions: ['log'] }], filters: [{ name: 'Log Files', extensions: ['log'] }],
}); });
if (!path) return; if (!path) return;
await invoke('export_logs', { destPath: path }); await invoke('export_logs', { destPath: path });
addToast({ message: 'Diagnostics exported', variant: 'success' }); addToast({ message: 'Support logs exported', variant: 'success' });
} catch (e) { } catch (e) {
console.error('Export failed:', e); console.error('Export failed:', e);
addToast({ message: `Could not export diagnostics: ${String(e)}`, variant: 'error', isActionable: true }); addToast({ message: `Could not export logs: ${String(e)}`, variant: 'error', isActionable: true });
} }
}; };
const handleClear = () => setLogs([]); const handleClear = () => {
clearedThroughRef.current = rawLineCountRef.current;
setLogs([]);
};
const severityClass = (level: string) => { const severityClass = (level: string) => {
switch (level) { switch (level) {
@@ -80,14 +89,14 @@ export default function DiagnosticsView() {
}; };
return ( return (
<div className="diagnostics-view flex-1 flex flex-col h-full overflow-hidden"> <div className="logs-view flex-1 flex flex-col h-full overflow-hidden">
<WindowDragRegion /> <WindowDragRegion />
{/* Toolbar */} {/* Toolbar */}
<div className="diagnostics-toolbar flex items-center justify-between px-4 py-2 shrink-0"> <div className="logs-toolbar flex items-center justify-between px-4 py-2 shrink-0">
<div className="flex items-center gap-2 text-text-secondary"> <div className="flex items-center gap-2 text-text-secondary">
<Terminal size={16} strokeWidth={1.8} /> <Terminal size={16} strokeWidth={1.8} />
<span className="text-[13px] font-semibold text-text-primary">Diagnostics Console</span> <span className="text-[13px] font-semibold text-text-primary">Logs</span>
<span className="text-[11px] text-text-muted">({logs.length} entries)</span> <span className="text-[11px] text-text-muted">({logs.length} entries)</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -110,7 +119,7 @@ export default function DiagnosticsView() {
<button <button
onClick={handleClear} onClick={handleClear}
className="app-icon-button" className="app-icon-button"
title="Clear console" title="Clear displayed logs"
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
@@ -126,9 +135,9 @@ export default function DiagnosticsView() {
</div> </div>
{/* Console */} {/* Console */}
<div ref={scrollRef} className="diagnostics-console flex-1 overflow-y-auto p-3 font-mono text-[11px] leading-[1.5]"> <div ref={scrollRef} className="logs-console flex-1 overflow-y-auto p-3 font-mono text-[11px] leading-[1.5]">
{logs.length === 0 && ( {logs.length === 0 && (
<div className="text-text-muted italic select-none">Waiting for log entries...</div> <div className="text-text-muted italic select-none">No persisted log entries are available yet.</div>
)} )}
{logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => ( {logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
<div key={i} className={`log-line ${severityClass(entry.level)}`}> <div key={i} className={`log-line ${severityClass(entry.level)}`}>
+73 -11
View File
@@ -26,6 +26,11 @@ const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[
{ value: 'shutdown', label: 'Shut down', icon: Power }, { value: 'shutdown', label: 'Shut down', icon: Power },
]; ];
const minuteOfDay = (value: string) => {
const [hour, minute] = value.split(':').map(Number);
return hour * 60 + minute;
};
function nextScheduledRun(settings: SchedulerSettings): string { function nextScheduledRun(settings: SchedulerSettings): string {
if (!settings.enabled) return 'Scheduler is disabled'; if (!settings.enabled) return 'Scheduler is disabled';
@@ -55,6 +60,7 @@ export default function SchedulerView() {
const savedSettings = useSettingsStore(state => state.scheduler); const savedSettings = useSettingsStore(state => state.scheduler);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning); const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const setScheduler = useSettingsStore(state => state.setScheduler); const setScheduler = useSettingsStore(state => state.setScheduler);
const queues = useDownloadStore(state => state.queues);
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings); const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
const { addToast } = useToast(); const { addToast } = useToast();
const [permissionMessage, setPermissionMessage] = useState(''); const [permissionMessage, setPermissionMessage] = useState('');
@@ -81,12 +87,41 @@ export default function SchedulerView() {
})); }));
}; };
const availableQueueIds = new Set(queues.map(queue => queue.id));
const selectedQueueIds = draft.selectedQueueIds.filter(queueId => availableQueueIds.has(queueId));
const effectiveSelectedQueueIds = selectedQueueIds.length > 0
? selectedQueueIds
: [MAIN_QUEUE_ID];
const toggleQueue = (queueId: string) => {
setDraft(current => {
const isSelected = current.selectedQueueIds.includes(queueId);
const availableSelectionCount = current.selectedQueueIds
.filter(id => availableQueueIds.has(id))
.length;
if (isSelected && availableSelectionCount === 1) return current;
return {
...current,
selectedQueueIds: isSelected
? current.selectedQueueIds.filter(id => id !== queueId)
: [...current.selectedQueueIds, queueId]
};
});
};
const save = () => { const save = () => {
if (!draft.everyday && draft.selectedDays.length === 0) {
addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true });
return;
}
if (draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
addToast({ message: 'Stop time must be later than start time', variant: 'error', isActionable: true });
return;
}
const normalized = { const normalized = {
...draft, ...draft,
selectedDays: draft.everyday || draft.selectedDays.length > 0 selectedDays: draft.selectedDays,
? draft.selectedDays selectedQueueIds: effectiveSelectedQueueIds
: savedSettings.selectedDays
}; };
setScheduler(normalized); setScheduler(normalized);
setDraft(normalized); setDraft(normalized);
@@ -94,18 +129,27 @@ export default function SchedulerView() {
}; };
const runNow = async () => { const runNow = async () => {
const count = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID); const results = await Promise.all(
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
);
const count = results.reduce((total, ids) => total + ids.length, 0);
const acceptedIds = results.flat();
if (count > 0) { if (count > 0) {
useSettingsStore.getState().setSchedulerRunning(true); useSettingsStore.getState().setSchedulerRunning(true);
useSettingsStore.getState().setSchedulerActiveDownloadIds(acceptedIds);
addToast({ message: `Started ${count} download${count === 1 ? '' : 's'}`, variant: 'success' }); addToast({ message: `Started ${count} download${count === 1 ? '' : 's'}`, variant: 'success' });
} else { } else {
addToast({ message: 'No paused or failed downloads to start', variant: 'info' }); addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' });
} }
}; };
const pauseNow = async () => { const pauseNow = async () => {
const count = await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID); const counts = await Promise.all(
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
);
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
useSettingsStore.getState().setSchedulerRunning(false); useSettingsStore.getState().setSchedulerRunning(false);
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
addToast({ message: count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads', variant: 'info' }); addToast({ message: count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads', variant: 'info' });
}; };
@@ -230,6 +274,9 @@ export default function SchedulerView() {
<input type="time" value={draft.stopTime} onChange={event => updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" /> <input type="time" value={draft.stopTime} onChange={event => updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" />
</div> </div>
</div> </div>
<p className="mt-4 text-[11px] text-text-muted">
If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed.
</p>
<div className="my-5 border-t border-border-color" /> <div className="my-5 border-t border-border-color" />
<label className="flex items-center gap-2 text-[13px] font-medium text-text-primary"> <label className="flex items-center gap-2 text-[13px] font-medium text-text-primary">
@@ -262,11 +309,26 @@ export default function SchedulerView() {
<div className="mb-4 flex items-center gap-2 font-semibold text-text-primary"> <div className="mb-4 flex items-center gap-2 font-semibold text-text-primary">
<List size={17} className="text-accent" /> Queues to Schedule <List size={17} className="text-accent" /> Queues to Schedule
</div> </div>
<label className="flex items-center gap-3 text-[13px] text-text-primary"> <div className="space-y-3">
<input type="checkbox" checked readOnly disabled={!draft.enabled} className="accent-accent" /> {queues.map(queue => {
Main Queue const selected = draft.selectedQueueIds.includes(queue.id);
<span className="text-[11px] text-text-muted">All paused and failed downloads</span> return (
</label> <label key={queue.id} className="flex items-center gap-3 text-[13px] text-text-primary">
<input
type="checkbox"
checked={selected}
onChange={() => toggleQueue(queue.id)}
disabled={!draft.enabled || (selected && selectedQueueIds.length === 1)}
className="accent-accent"
/>
{queue.name}
{queue.isMain && (
<span className="text-[11px] text-text-muted">Default queue</span>
)}
</label>
);
})}
</div>
</section> </section>
<section className="app-card p-5"> <section className="app-card p-5">
+1 -1
View File
@@ -177,7 +177,7 @@ export default function SettingsView() {
const settings = useSettingsStore(); const settings = useSettingsStore();
const activeTab = settings.activeSettingsTab; const activeTab = settings.activeSettingsTab;
// Local state for engine diagnostics // Local state for engine status
const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null); const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null);
const [expandedEngine, setExpandedEngine] = useState<string | null>(null); const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false); const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
+14 -2
View File
@@ -8,6 +8,7 @@ import {
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore'; import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
import { ActiveView, useSettingsStore } from '../store/useSettingsStore'; import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion'; import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string; export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
@@ -20,6 +21,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const { selectedFilter, onSelectFilter } = props; const { selectedFilter, onSelectFilter } = props;
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore(); const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
const { activeView, setActiveView, toggleSidebar } = useSettingsStore(); const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
const { addToast } = useToast();
const [isAddingQueue, setIsAddingQueue] = useState(false); const [isAddingQueue, setIsAddingQueue] = useState(false);
const [newQueueName, setNewQueueName] = useState(''); const [newQueueName, setNewQueueName] = useState('');
@@ -236,7 +238,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<div className="sidebar-section-label">Tools</div> <div className="sidebar-section-label">Tools</div>
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" /> <ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" /> <ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
<ToolItem icon={Bug} label="Diagnostics" view="diagnostics" /> <ToolItem icon={Bug} label="Logs" view="logs" />
</section> </section>
</div> </div>
@@ -294,7 +296,17 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
{!queues.find(q => q.id === contextMenu.id)?.isMain && ( {!queues.find(q => q.id === contextMenu.id)?.isMain && (
<button <button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400" className="w-full text-left px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400"
onClick={() => { removeQueue(contextMenu.id); setContextMenu(null); }} onClick={() => {
const queueId = contextMenu.id;
setContextMenu(null);
void removeQueue(queueId).catch(error => {
addToast({
message: `Could not delete queue: ${String(error)}`,
variant: 'error',
isActionable: true
});
});
}}
> >
<Trash2 size={14} className="mr-2" /> <Trash2 size={14} className="mr-2" />
Delete Queue Delete Queue
+26 -16
View File
@@ -1427,6 +1427,8 @@
.download-table-scroll { .download-table-scroll {
flex: 1; flex: 1;
display: flex;
flex-direction: column;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
overflow-x: hidden; overflow-x: hidden;
@@ -1475,10 +1477,18 @@
} }
.download-table-body { .download-table-body {
height: 100%; flex: 1;
min-height: 0;
overflow: hidden; overflow: hidden;
} }
.download-table-list {
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
}
.download-row { .download-row {
height: 32px; height: 32px;
display: grid; display: grid;
@@ -1727,20 +1737,20 @@ html[data-list-density="relaxed"] .download-ghost-row {
} }
} }
/* Diagnostics Console */ /* Logs Console */
.diagnostics-toolbar { .logs-toolbar {
height: 42px; height: 42px;
border-bottom: 1px solid hsl(var(--border-color)); border-bottom: 1px solid hsl(var(--border-color));
background: hsl(var(--statusbar-bg)); background: hsl(var(--statusbar-bg));
} }
.diagnostics-console { .logs-console {
background: hsl(0 0% 7%); background: hsl(0 0% 7%);
color: hsl(0 0% 82%); color: hsl(0 0% 82%);
font-family: "SF Mono", Monaco, "Cascadia Code", "Fira Code", "JetBrains Mono", monospace; font-family: "SF Mono", Monaco, "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
} }
.diagnostics-console .log-line { .logs-console .log-line {
display: flex; display: flex;
gap: 8px; gap: 8px;
padding: 0 4px; padding: 0 4px;
@@ -1751,39 +1761,39 @@ html[data-list-density="relaxed"] .download-ghost-row {
word-break: break-all; word-break: break-all;
} }
.diagnostics-console .log-level-tag { .logs-console .log-level-tag {
flex-shrink: 0; flex-shrink: 0;
font-weight: 600; font-weight: 600;
min-width: 52px; min-width: 52px;
font-size: 10px; font-size: 10px;
} }
.diagnostics-console .log-message { .logs-console .log-message {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.diagnostics-console .log-error .log-level-tag, .logs-console .log-error .log-level-tag,
.diagnostics-console .log-error .log-message { .logs-console .log-error .log-message {
color: hsl(0 72% 58%); color: hsl(0 72% 58%);
} }
.diagnostics-console .log-warn .log-level-tag, .logs-console .log-warn .log-level-tag,
.diagnostics-console .log-warn .log-message { .logs-console .log-warn .log-message {
color: hsl(45 100% 50%); color: hsl(45 100% 50%);
} }
.diagnostics-console .log-info .log-level-tag, .logs-console .log-info .log-level-tag,
.diagnostics-console .log-info .log-message { .logs-console .log-info .log-message {
color: hsl(0 0% 75%); color: hsl(0 0% 75%);
} }
.diagnostics-console .log-debug .log-level-tag, .logs-console .log-debug .log-level-tag,
.diagnostics-console .log-debug .log-message { .logs-console .log-debug .log-message {
color: hsl(0 0% 45%); color: hsl(0 0% 45%);
} }
.diagnostics-console .log-line:hover { .logs-console .log-line:hover {
background: hsl(0 0% 100% / 0.04); background: hsl(0 0% 100% / 0.04);
} }
+9 -7
View File
@@ -12,6 +12,7 @@ import type { PostQueueAction } from './bindings/PostQueueAction';
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome'; import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
import type { PairingTokenHydration } from './bindings/PairingTokenHydration'; import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
import type { EnqueueItem } from './bindings/EnqueueItem'; import type { EnqueueItem } from './bindings/EnqueueItem';
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
type CommandMap = { type CommandMap = {
fetch_metadata: { fetch_metadata: {
@@ -28,14 +29,14 @@ type CommandMap = {
get_deno_engine_status: { args: undefined; result: EngineStatusItem }; get_deno_engine_status: { args: undefined; result: EngineStatusItem };
reveal_in_file_manager: { args: { path: string }; result: void }; reveal_in_file_manager: { args: { path: string }; result: void };
open_downloaded_file: { args: { path: string }; result: void }; open_downloaded_file: { args: { path: string }; result: void };
trash_download_assets: { args: { path: string; partialPaths: string[] }; result: void };
pause_download: { args: { id: string }; result: void }; pause_download: { args: { id: string }; result: void };
resume_download: { args: { id: string }; result: boolean }; resume_download: { args: { id: string }; result: boolean };
remove_download: { args: { id: string; filepath: string | null }; result: void }; remove_download: { args: { id: string; deleteAssets: boolean }; result: void };
detach_download_for_reconfigure: { args: { id: string }; result: void }; detach_download_for_reconfigure: { args: { id: string }; result: void };
update_dock_badge: { args: { count: number }; result: void }; update_dock_badge: { args: { count: number }; result: void };
set_prevent_sleep: { args: { prevent: boolean }; result: void }; set_prevent_sleep: { args: { prevent: boolean }; result: void };
perform_system_action: { args: { action: PostQueueAction }; result: void }; perform_system_action: { args: { action: PostQueueAction }; result: void };
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
set_concurrent_limit: { args: { limit: number }; result: void }; set_concurrent_limit: { args: { limit: number }; result: void };
set_global_speed_limit: { args: { limit: string | null }; result: void }; set_global_speed_limit: { args: { limit: string | null }; result: void };
request_automation_permission: { args: undefined; result: void }; request_automation_permission: { args: undefined; result: void };
@@ -67,10 +68,11 @@ type CommandMap = {
result: void; result: void;
}; };
export_logs: { args: { destPath: string }; result: string }; export_logs: { args: { destPath: string }; result: string };
get_pending_order: { args: undefined; result: string[] }; read_logs: { args: { limit: number }; result: string[] };
enqueue_download: { args: { item: EnqueueItem }; result: string }; get_pending_order: { args: { queueId: string | null }; result: string[] };
enqueue_download: { args: { item: EnqueueItem }; result: EnqueueAccepted };
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] }; enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
move_in_queue: { args: { id: string; direction: 'up' | 'down' }; result: string[] }; move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
remove_from_queue: { args: { id: string }; result: boolean }; remove_from_queue: { args: { id: string }; result: boolean };
}; };
@@ -83,13 +85,13 @@ export function invokeCommand<K extends CommandName>(
...args: CommandArgs<K> extends undefined ? [] : [args: CommandArgs<K>] ...args: CommandArgs<K> extends undefined ? [] : [args: CommandArgs<K>]
): Promise<CommandResult<K>> { ): Promise<CommandResult<K>> {
return tauriInvoke<CommandResult<K>>(command, args[0]).catch(err => { return tauriInvoke<CommandResult<K>>(command, args[0]).catch(err => {
logError(`Invoke command ${command} failed: ${err}`); void logError(`Invoke command ${command} failed: ${err}`).catch(() => undefined);
throw err; throw err;
}); });
} }
type EventMap = { type EventMap = {
'schedule-trigger': 'start' | 'stop'; 'schedule-trigger': { action: 'start' | 'stop'; key: string };
'download-progress': DownloadProgressEvent; 'download-progress': DownloadProgressEvent;
'download-state': DownloadStateEvent; 'download-state': DownloadStateEvent;
'download-complete': string; 'download-complete': string;
+26
View File
@@ -4,6 +4,32 @@ import "./index.css";
import App from "./App"; import App from "./App";
import { ErrorBoundary } from "./ErrorBoundary"; import { ErrorBoundary } from "./ErrorBoundary";
import { ToastProvider } from "./contexts/ToastContext"; import { ToastProvider } from "./contexts/ToastContext";
import { error as logError, warn as logWarn } from "@tauri-apps/plugin-log";
const serializeConsoleArguments = (values: unknown[]) => values.map(value => {
if (value instanceof Error) return `${value.name}: ${value.message}\n${value.stack || ''}`;
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}).join(' ');
const redactConsoleMessage = (message: string) => message
.replace(/(authorization|cookie|password|token|secret)\s*[:=]\s*([^\s,;]+)/gi, '$1=[redacted]')
.replace(/(https?:\/\/[^\s?]+)\?[^\s]+/g, '$1?[redacted]');
const originalConsoleError = console.error.bind(console);
const originalConsoleWarn = console.warn.bind(console);
console.error = (...values: unknown[]) => {
originalConsoleError(...values);
void logError(redactConsoleMessage(serializeConsoleArguments(values))).catch(() => undefined);
};
console.warn = (...values: unknown[]) => {
originalConsoleWarn(...values);
void logWarn(redactConsoleMessage(serializeConsoleArguments(values))).catch(() => undefined);
};
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
if (rootElement) { if (rootElement) {
+71 -5
View File
@@ -71,7 +71,7 @@ describe('useDownloadStore', () => {
}); });
const dispatched = await useDownloadStore.getState().startQueue('MAIN'); const dispatched = await useDownloadStore.getState().startQueue('MAIN');
expect(dispatched).toBe(2); // Both items counted as dispatched/handled expect(dispatched).toEqual(['1', '2']);
const calls = vi.mocked(ipc.invokeCommand).mock.calls; const calls = vi.mocked(ipc.invokeCommand).mock.calls;
const enqueues = calls.filter(c => c[0] === 'enqueue_download'); const enqueues = calls.filter(c => c[0] === 'enqueue_download');
@@ -79,6 +79,34 @@ describe('useDownloadStore', () => {
expect((enqueues[0] as any)[1].item.id).toBe('2'); expect((enqueues[0] as any)[1].item.id).toBe('2');
}); });
it('does not overwrite a downloading event received while starting a queue', async () => {
useDownloadStore.setState({
downloads: [
{ id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
] as any[],
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'enqueue_download') {
useDownloadStore.getState().updateDownload('1', {
status: 'downloading',
speed: '1 MB/s',
eta: '10s'
});
}
if (cmd === 'get_pending_order') return ['1'];
return undefined;
});
expect(await useDownloadStore.getState().startQueue('MAIN')).toEqual(['1']);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'downloading',
speed: '1 MB/s',
eta: '10s',
hasBeenDispatched: true
});
});
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => { it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
useDownloadStore.setState({ useDownloadStore.setState({
downloads: [ downloads: [
@@ -113,9 +141,9 @@ describe('useDownloadStore', () => {
}, { type: 'add-to-queue', queueId: 'queue-b' }); }, { type: 'add-to-queue', queueId: 'queue-b' });
const item = useDownloadStore.getState().downloads[0]; const item = useDownloadStore.getState().downloads[0];
expect(item.status).toBe('queued'); expect(item.status).toBe('staged');
expect(item.queueId).toBe('queue-b'); expect(item.queueId).toBe('queue-b');
expect(useDownloadStore.getState().pendingOrder).toContain('queue-1'); expect(item.queuePosition).toBe(0);
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything()); expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
}); });
@@ -207,7 +235,7 @@ describe('useDownloadStore', () => {
).toHaveLength(4); ).toHaveLength(4);
}); });
it('assigns selected unfinished downloads to a queue without moving completed items', () => { it('assigns selected unfinished downloads to a queue without moving completed items', async () => {
useDownloadStore.setState({ useDownloadStore.setState({
downloads: [ downloads: [
{ id: 'ready', status: 'ready', queueId: 'old' }, { id: 'ready', status: 'ready', queueId: 'old' },
@@ -215,12 +243,50 @@ describe('useDownloadStore', () => {
] as any[] ] as any[]
}); });
useDownloadStore.getState().assignToQueue(['ready', 'done'], 'new'); await useDownloadStore.getState().assignToQueue(['ready', 'done'], 'new');
expect(useDownloadStore.getState().downloads.find(item => item.id === 'ready')?.queueId).toBe('new'); expect(useDownloadStore.getState().downloads.find(item => item.id === 'ready')?.queueId).toBe('new');
expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old'); expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old');
}); });
it('retains the UI item when backend removal fails', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'active', url: 'https://example.com/file', fileName: 'file', status: 'downloading', category: 'Other', dateAdded: '', queueId: 'main' }
] as any[]
});
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('writer did not stop'));
await expect(useDownloadStore.getState().removeDownload('active', true))
.rejects.toThrow('writer did not stop');
expect(useDownloadStore.getState().downloads.map(download => download.id))
.toEqual(['active']);
});
it('starts staged queue items in their persisted queue order', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'later', url: 'https://example.com/later', fileName: 'later', status: 'staged', category: 'Other', dateAdded: '', queueId: 'queue-a', queuePosition: 1 },
{ id: 'first', url: 'https://example.com/first', fileName: 'first', status: 'staged', category: 'Other', dateAdded: '', queueId: 'queue-a', queuePosition: 0 }
] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
if (command === 'get_pending_order') {
return [(args as { queueId: string }).queueId === 'queue-a' ? 'first' : 'later'];
}
return undefined;
});
expect(await useDownloadStore.getState().startQueue('queue-a')).toEqual(['first', 'later']);
const enqueuedIds = vi.mocked(ipc.invokeCommand).mock.calls
.filter(call => call[0] === 'enqueue_download')
.map(call => (call[1] as any).item.id);
expect(enqueuedIds).toEqual(['first', 'later']);
expect((vi.mocked(ipc.invokeCommand).mock.calls.find(call =>
call[0] === 'enqueue_download'
)?.[1] as any).item.queue_id).toBe('queue-a');
});
it('preserves extension request headers and cookies for the Add modal', () => { it('preserves extension request headers and cookies for the Add modal', () => {
useDownloadStore.getState().handleExtensionDownload({ useDownloadStore.getState().handleExtensionDownload({
urls: ['https://example.com/file.bin'], urls: ['https://example.com/file.bin'],
+130 -61
View File
@@ -7,11 +7,9 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload'; import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { Queue } from '../bindings/Queue'; import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore'; import { useSettingsStore } from './useSettingsStore';
import { isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads'; import { categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import { import {
expandTilde, resolveCategoryDestination
resolveCategoryDestination,
resolveDownloadFilePath
} from '../utils/downloadLocations'; } from '../utils/downloadLocations';
import { canPauseDownload, canStartDownload } from '../utils/downloadActions'; import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
@@ -42,6 +40,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
const enqueueItem = { const enqueueItem = {
id: item.id, id: item.id,
queue_id: item.queueId || MAIN_QUEUE_ID,
url: item.url, url: item.url,
destination, destination,
filename: item.fileName, filename: item.fileName,
@@ -61,8 +60,15 @@ export async function dispatchItem(id: string): Promise<boolean> {
is_media: item.isMedia || false is_media: item.isMedia || false
}; };
await invoke('enqueue_download', { item: enqueueItem }); const accepted = await invoke('enqueue_download', { item: enqueueItem });
const order = await invoke('get_pending_order'); const acceptedFilename = accepted?.filename || item.fileName;
if (acceptedFilename !== item.fileName) {
useDownloadStore.getState().updateDownload(id, {
fileName: acceptedFilename,
category: categoryForFileName(acceptedFilename)
});
}
const order = await invoke('get_pending_order', { queueId: item.queueId || MAIN_QUEUE_ID });
useDownloadStore.getState().setPendingOrder(order); useDownloadStore.getState().setPendingOrder(order);
useDownloadStore.getState().registerBackendIds([id]); useDownloadStore.getState().registerBackendIds([id]);
return true; return true;
@@ -126,16 +132,26 @@ const syncSystemIntegrations = () => {
} }
}; };
const resolveDownloadPath = async (destination: string, fileName: string) => {
return resolveDownloadFilePath(await expandTilde(destination), fileName);
};
const effectiveDestinationForItem = async ( const effectiveDestinationForItem = async (
item: Pick<DownloadItem, 'destination' | 'category'>, item: Pick<DownloadItem, 'destination' | 'category'>,
settings: ReturnType<typeof useSettingsStore.getState> settings: ReturnType<typeof useSettingsStore.getState>
): Promise<string> => ): Promise<string> =>
item.destination || resolveCategoryDestination(settings, item.category); item.destination || resolveCategoryDestination(settings, item.category);
const normalizeQueuePositions = (downloads: DownloadItem[]): DownloadItem[] => {
const nextPosition = new Map<string, number>();
return downloads.map(download => {
const queueId = download.queueId || MAIN_QUEUE_ID;
const position = nextPosition.get(queueId) || 0;
nextPosition.set(queueId, position + 1);
return {
...download,
queueId,
queuePosition: download.queuePosition ?? position
};
});
};
export type { DownloadStatus }; export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001'; export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
@@ -186,15 +202,15 @@ interface DownloadState {
updateDownload: (id: string, updates: Partial<DownloadItem>) => void; updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>; removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
redownload: (id: string) => Promise<void>; redownload: (id: string) => Promise<void>;
resumeDownload: (id: string) => Promise<void>; resumeDownload: (id: string) => Promise<boolean>;
startQueue: (queueId: string) => Promise<number>; startQueue: (queueId: string) => Promise<string[]>;
pauseQueue: (queueId: string) => Promise<number>; pauseQueue: (queueId: string) => Promise<number>;
startAll: () => Promise<number>; startAll: () => Promise<number>;
pauseAll: () => Promise<number>; pauseAll: () => Promise<number>;
assignToQueue: (ids: string[], queueId: string) => void; assignToQueue: (ids: string[], queueId: string) => Promise<void>;
addQueue: (name: string) => void; addQueue: (name: string) => void;
renameQueue: (id: string, name: string) => void; renameQueue: (id: string, name: string) => void;
removeQueue: (id: string) => void; removeQueue: (id: string) => Promise<void>;
initDB: () => Promise<void>; initDB: () => Promise<void>;
} }
@@ -205,8 +221,27 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingOrder: [], pendingOrder: [],
setPendingOrder: (order) => set({ pendingOrder: order }), setPendingOrder: (order) => set({ pendingOrder: order }),
moveInQueue: async (id, direction) => { moveInQueue: async (id, direction) => {
const item = get().downloads.find(download => download.id === id);
if (!item) return;
const queueId = item.queueId || MAIN_QUEUE_ID;
const queueItems = get().downloads
.filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId && download.status !== 'completed')
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
const index = queueItems.findIndex(download => download.id === id);
const target = direction === 'up' ? index - 1 : index + 1;
if (index < 0 || target < 0 || target >= queueItems.length) return;
const reordered = [...queueItems];
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
const positions = new Map(reordered.map((download, position) => [download.id, position]));
set(state => ({
downloads: state.downloads.map(download => positions.has(download.id)
? { ...download, queuePosition: positions.get(download.id) }
: download)
}));
if (!get().backendRegisteredIds.has(id)) return;
try { try {
const order = await invoke('move_in_queue', { id, direction }); const order = await invoke('move_in_queue', { id, queueId, direction });
set({ pendingOrder: order }); set({ pendingOrder: order });
} catch (e) { } catch (e) {
console.error("Failed to move item in queue:", e); console.error("Failed to move item in queue:", e);
@@ -284,20 +319,21 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
addDownload: async (item, action) => { addDownload: async (item, action) => {
const settings = useSettingsStore.getState(); const settings = useSettingsStore.getState();
const destPath = await effectiveDestinationForItem(item, settings); const destPath = await effectiveDestinationForItem(item, settings);
const queueId = action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID;
const queuePosition = get().downloads.filter(download =>
(download.queueId || MAIN_QUEUE_ID) === queueId && download.status !== 'completed'
).length;
const ownedItem: DownloadItem = { const ownedItem: DownloadItem = {
...item, ...item,
destination: destPath, destination: destPath,
status: action.type === 'add-to-queue' ? 'queued' : 'ready', status: action.type === 'add-to-queue' ? 'staged' : 'ready',
queueId: action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID, queueId,
queuePosition,
hasBeenDispatched: false hasBeenDispatched: false
}; };
set((state) => ({ downloads: [...state.downloads, ownedItem] })); set((state) => ({ downloads: [...state.downloads, ownedItem] }));
if (action.type === 'add-to-queue') { if (action.type === 'add-to-queue') {
const order = useDownloadStore.getState().pendingOrder;
if (!order.includes(item.id)) {
useDownloadStore.getState().setPendingOrder([...order, item.id]);
}
info(`Download ${item.id} added to queue ${action.queueId}`); info(`Download ${item.id} added to queue ${action.queueId}`);
} else if (action.type === 'start-now') { } else if (action.type === 'start-now') {
if (await dispatchItem(item.id)) { if (await dispatchItem(item.id)) {
@@ -315,7 +351,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
throw new Error("Cannot change properties while transfer is active. Pause it first."); throw new Error("Cannot change properties while transfer is active. Pause it first.");
} }
if (item.status === 'ready' || item.status === 'completed' || item.status === 'failed') { if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') {
state.updateDownload(id, updates); state.updateDownload(id, updates);
return; return;
} }
@@ -373,18 +409,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
removeDownload: async (id, deleteFile = false) => { removeDownload: async (id, deleteFile = false) => {
const item = get().downloads.find(d => d.id === id); const item = get().downloads.find(d => d.id === id);
if (item && deleteFile) {
const filepath = await resolveDownloadPath(item.destination || '~/Downloads', item.fileName);
const partialPaths = [`${filepath}.aria2`, `${filepath}.part`];
await invoke('trash_download_assets', { path: filepath, partialPaths });
}
if (item) { if (item) {
try { await invoke('remove_download', { id, deleteAssets: deleteFile });
await invoke('remove_download', { id, filepath: null });
} catch (e) {
console.error("Failed to terminate download on backend during deletion, but will still remove from UI:", e);
}
} }
set((state) => ({ set((state) => ({
@@ -464,19 +490,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}, },
resumeDownload: async (id) => { resumeDownload: async (id) => {
const targetItem = get().downloads.find(d => d.id === id); const targetItem = get().downloads.find(d => d.id === id);
if (!targetItem) return; if (!targetItem) return false;
try { try {
if (targetItem.status === 'ready') { if (targetItem.status === 'ready' || targetItem.status === 'staged') {
if (await dispatchItem(id)) { if (await dispatchItem(id)) {
get().updateDownload(id, { hasBeenDispatched: true }); get().updateDownload(id, { hasBeenDispatched: true });
return true;
} }
return; return false;
} }
const resumedExisting = await invoke('resume_download', { id }); const resumedExisting = await invoke('resume_download', { id });
if (resumedExisting) { if (resumedExisting) {
return; return true;
} }
get().unregisterBackendIds([id]); get().unregisterBackendIds([id]);
@@ -492,38 +519,50 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (!await dispatchItem(id)) { if (!await dispatchItem(id)) {
console.error("Failed to re-enqueue for resume"); console.error("Failed to re-enqueue for resume");
return false;
} }
return true;
} catch (e) { } catch (e) {
console.error("Failed to resume download:", e); console.error("Failed to resume download:", e);
return false;
} }
}, },
startQueue: async (queueId) => { startQueue: async (queueId) => {
const runnable = get().downloads const runnable = get().downloads
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status))); .filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)))
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
if (runnable.length === 0) return 0; if (runnable.length === 0) return [];
let dispatchedCount = 0; const acceptedIds: string[] = [];
const promises = runnable.map(async (item) => { for (const item of runnable) {
if (item.status === 'ready' || item.status === 'failed' || !item.hasBeenDispatched) { if (
item.status === 'ready' ||
item.status === 'staged' ||
item.status === 'failed' ||
!item.hasBeenDispatched ||
!get().backendRegisteredIds.has(item.id)
) {
if (await dispatchItem(item.id)) { if (await dispatchItem(item.id)) {
get().updateDownload(item.id, { hasBeenDispatched: true, status: 'queued' }); const current = get().downloads.find(download => download.id === item.id);
dispatchedCount++; get().updateDownload(item.id, {
hasBeenDispatched: true,
...(current?.status === item.status ? { status: 'queued' as const } : {})
});
acceptedIds.push(item.id);
} }
} else if (item.status === 'paused' || item.status === 'queued') { } else if (item.status === 'paused' || item.status === 'queued') {
// If it's queued but already dispatched, it might be waiting. // If it's queued but already dispatched, it might be waiting.
// If it's paused, we resume it. // If it's paused, we resume it.
if (item.status === 'paused') { if (item.status === 'paused') {
await get().resumeDownload(item.id); if (!await get().resumeDownload(item.id)) continue;
} }
dispatchedCount++; acceptedIds.push(item.id);
} }
}); }
await Promise.all(promises); info(`Queue ${queueId} started, ${acceptedIds.length} items dispatched/resumed`);
return acceptedIds;
info(`Queue ${queueId} started, ${dispatchedCount} items dispatched/resumed`);
return dispatchedCount;
}, },
pauseQueue: async (queueId) => { pauseQueue: async (queueId) => {
const activeIds = get().downloads const activeIds = get().downloads
@@ -556,7 +595,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
.map(item => item.queueId || MAIN_QUEUE_ID) .map(item => item.queueId || MAIN_QUEUE_ID)
); );
const results = await Promise.all(Array.from(queueIds, queueId => get().startQueue(queueId))); const results = await Promise.all(Array.from(queueIds, queueId => get().startQueue(queueId)));
return results.reduce((total, count) => total + count, 0); return results.reduce((total, ids) => total + ids.length, 0);
}, },
pauseAll: async () => { pauseAll: async () => {
const activeIds = get().downloads const activeIds = get().downloads
@@ -571,12 +610,39 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
syncSystemIntegrations(); syncSystemIntegrations();
return pausedCount; return pausedCount;
}, },
assignToQueue: (ids, queueId) => { assignToQueue: async (ids, queueId) => {
const selectedIds = new Set(ids); const selectedIds = new Set(ids);
const selected = get().downloads.filter(item => selectedIds.has(item.id));
const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued');
if (locked) {
throw new Error(`Pause ${locked.fileName} before moving it to another queue.`);
}
for (const item of selected) {
if (!get().backendRegisteredIds.has(item.id)) continue;
if (item.status === 'queued') {
await invoke('remove_from_queue', { id: item.id });
} else if (item.status === 'paused') {
await invoke('detach_download_for_reconfigure', { id: item.id });
}
get().unregisterBackendIds([item.id]);
}
const nextPosition = get().downloads.filter(item =>
!selectedIds.has(item.id) &&
(item.queueId || MAIN_QUEUE_ID) === queueId &&
item.status !== 'completed'
).length;
set(state => ({ set(state => ({
downloads: state.downloads.map(item => downloads: state.downloads.map(item =>
selectedIds.has(item.id) && item.status !== 'completed' selectedIds.has(item.id) && item.status !== 'completed'
? { ...item, queueId } ? {
...item,
queueId,
queuePosition: nextPosition + selected.findIndex(selectedItem => selectedItem.id === item.id),
status: 'staged' as const,
hasBeenDispatched: false
}
: item : item
) )
})); }));
@@ -599,9 +665,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}) })
})); }));
}, },
removeQueue: (id) => { removeQueue: async (id) => {
if (id === MAIN_QUEUE_ID) return; if (id === MAIN_QUEUE_ID) return;
const unfinishedIds = get().downloads
.filter(download => download.queueId === id && download.status !== 'completed')
.map(download => download.id);
if (unfinishedIds.length > 0) {
await get().assignToQueue(unfinishedIds, MAIN_QUEUE_ID);
}
set((state) => ({ set((state) => ({
queues: state.queues.filter(q => q.id !== id), queues: state.queues.filter(q => q.id !== id),
downloads: state.downloads.map(d => downloads: state.downloads.map(d =>
@@ -619,10 +690,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
set(state => ({ set(state => ({
queues: queues.length > 0 ? queues : state.queues, queues: queues.length > 0 ? queues : state.queues,
downloads: downloads.length > 0 downloads: downloads.length > 0
? downloads.map(download => ({ ? normalizeQueuePositions(downloads)
...download,
queueId: download.queueId || MAIN_QUEUE_ID
}))
: state.downloads : state.downloads
})); }));
@@ -655,6 +723,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
await resolveCategoryDestination(settings, item.category); await resolveCategoryDestination(settings, item.category);
itemsToEnqueue.push({ itemsToEnqueue.push({
id: item.id, id: item.id,
queue_id: item.queueId || MAIN_QUEUE_ID,
url: item.url, url: item.url,
destination: destPath, destination: destPath,
filename: item.fileName, filename: item.fileName,
@@ -677,7 +746,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
const results = await invoke('enqueue_many', { items: itemsToEnqueue }); const results = await invoke('enqueue_many', { items: itemsToEnqueue });
const registeredIds = results.filter(result => result.success).map(result => result.id); const registeredIds = results.filter(result => result.success).map(result => result.id);
const failedIds = new Set(results.filter(result => !result.success).map(result => result.id)); const failedIds = new Set(results.filter(result => !result.success).map(result => result.id));
const order = await invoke('get_pending_order'); const order = await invoke('get_pending_order', { queueId: null });
set(state => ({ set(state => ({
pendingOrder: order, pendingOrder: order,
backendRegisteredIds: new Set([ backendRegisteredIds: new Set([
+24 -1
View File
@@ -19,6 +19,7 @@ import {
} from '../utils/downloadLocations'; } from '../utils/downloadLocations';
let settingsSave = Promise.resolve(); let settingsSave = Promise.resolve();
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
const tauriStorage: StateStorage = { const tauriStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => { getItem: async (name: string): Promise<string | null> => {
@@ -80,6 +81,7 @@ export interface SettingsState {
activeSettingsTab: SettingsTab; activeSettingsTab: SettingsTab;
scheduler: SchedulerSettings; scheduler: SchedulerSettings;
schedulerRunning: boolean; schedulerRunning: boolean;
schedulerActiveDownloadIds: string[];
schedulerLastStartKey: string; schedulerLastStartKey: string;
schedulerLastStopKey: string; schedulerLastStopKey: string;
lastCustomSpeedLimitKiB: number; lastCustomSpeedLimitKiB: number;
@@ -112,6 +114,7 @@ export interface SettingsState {
setActiveSettingsTab: (tab: SettingsTab) => void; setActiveSettingsTab: (tab: SettingsTab) => void;
setScheduler: (settings: SchedulerSettings) => void; setScheduler: (settings: SchedulerSettings) => void;
setSchedulerRunning: (running: boolean) => void; setSchedulerRunning: (running: boolean) => void;
setSchedulerActiveDownloadIds: (ids: string[]) => void;
setSchedulerLastStartKey: (key: string) => void; setSchedulerLastStartKey: (key: string) => void;
setSchedulerLastStopKey: (key: string) => void; setSchedulerLastStopKey: (key: string) => void;
setLastCustomSpeedLimitKiB: (limit: number) => void; setLastCustomSpeedLimitKiB: (limit: number) => void;
@@ -187,9 +190,11 @@ export const useSettingsStore = create<SettingsState>()(
stopTime: '08:00', stopTime: '08:00',
everyday: true, everyday: true,
selectedDays: [0, 1, 2, 3, 4, 5, 6], selectedDays: [0, 1, 2, 3, 4, 5, 6],
selectedQueueIds: [DEFAULT_SCHEDULER_QUEUE_ID],
postQueueAction: 'none' postQueueAction: 'none'
}, },
schedulerRunning: false, schedulerRunning: false,
schedulerActiveDownloadIds: [],
schedulerLastStartKey: '', schedulerLastStartKey: '',
schedulerLastStopKey: '', schedulerLastStopKey: '',
lastCustomSpeedLimitKiB: 1024, lastCustomSpeedLimitKiB: 1024,
@@ -231,6 +236,7 @@ export const useSettingsStore = create<SettingsState>()(
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }), setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
setScheduler: (scheduler) => set({ scheduler }), setScheduler: (scheduler) => set({ scheduler }),
setSchedulerRunning: (schedulerRunning) => set({ schedulerRunning }), setSchedulerRunning: (schedulerRunning) => set({ schedulerRunning }),
setSchedulerActiveDownloadIds: (schedulerActiveDownloadIds) => set({ schedulerActiveDownloadIds }),
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }), setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }), setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }), setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
@@ -301,7 +307,7 @@ export const useSettingsStore = create<SettingsState>()(
{ {
name: 'firelink-settings', name: 'firelink-settings',
storage: createJSONStorage(() => tauriStorage), storage: createJSONStorage(() => tauriStorage),
version: 2, version: 3,
migrate: (persistedState) => { migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') { if (!persistedState || typeof persistedState !== 'object') {
return persistedState as SettingsState; return persistedState as SettingsState;
@@ -316,6 +322,15 @@ export const useSettingsStore = create<SettingsState>()(
return { return {
...persisted, ...persisted,
...locations, ...locations,
scheduler: persisted.scheduler
? {
...persisted.scheduler,
selectedQueueIds: Array.isArray(persisted.scheduler.selectedQueueIds)
&& persisted.scheduler.selectedQueueIds.length > 0
? persisted.scheduler.selectedQueueIds
: [DEFAULT_SCHEDULER_QUEUE_ID]
}
: persisted.scheduler,
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : [] siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : []
} as SettingsState; } as SettingsState;
}, },
@@ -360,6 +375,14 @@ export const useSettingsStore = create<SettingsState>()(
...currentState, ...currentState,
...persisted, ...persisted,
...locations, ...locations,
scheduler: {
...currentState.scheduler,
...persisted.scheduler,
selectedQueueIds: Array.isArray(persisted.scheduler?.selectedQueueIds)
&& persisted.scheduler.selectedQueueIds.length > 0
? persisted.scheduler.selectedQueueIds
: currentState.scheduler.selectedQueueIds
},
appFontSize: persisted.appFontSize || currentState.appFontSize, appFontSize: persisted.appFontSize || currentState.appFontSize,
listRowDensity: persisted.listRowDensity || currentState.listRowDensity, listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
siteLogins: Array.isArray(persisted.siteLogins) siteLogins: Array.isArray(persisted.siteLogins)
+2 -1
View File
@@ -2,6 +2,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
const STARTABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([ const STARTABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'ready', 'ready',
'staged',
'paused', 'paused',
'failed', 'failed',
]); ]);
@@ -29,7 +30,7 @@ export const canRedownload = (status: DownloadStatus): boolean =>
REDOWNLOADABLE_STATUSES.has(status); REDOWNLOADABLE_STATUSES.has(status);
export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' => export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' =>
status === 'ready' || status === 'failed' ? 'Start' : 'Resume'; status === 'ready' || status === 'staged' || status === 'failed' ? 'Start' : 'Resume';
export const isTransferLocked = (status: DownloadStatus): boolean => export const isTransferLocked = (status: DownloadStatus): boolean =>
status === 'downloading' || status === 'processing' || status === 'retrying'; status === 'downloading' || status === 'processing' || status === 'retrying';
+9
View File
@@ -83,6 +83,15 @@ export const fileNameFromUrl = (rawUrl: string): string => {
return 'download'; return 'download';
}; };
export const canonicalizeDownloadFileName = (fileName: string): string => {
const leaf = fileName.replace(/\\/g, '/').split('/').pop() || 'download';
const sanitized = leaf
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-')
.trim()
.replace(/[. ]+$/g, '');
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
};
export const isMediaUrl = (rawUrl: string): boolean => { export const isMediaUrl = (rawUrl: string): boolean => {
try { try {
const url = new URL(rawUrl); const url = new URL(rawUrl);