mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-03 16:08:02 +00:00
fix(core): harden download lifecycle and scheduling
This commit is contained in:
+1
-1
@@ -333,7 +333,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.4.0] - 2026-06-03
|
||||
|
||||
### 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.
|
||||
- Prefer the bundled `aria2c` binary inside release builds.
|
||||
|
||||
|
||||
@@ -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-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-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 |
|
||||
|
||||
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-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-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 |
|
||||
@@ -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 |
|
||||
|---|---|---|---|---|---|---|
|
||||
|
||||
@@ -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.
|
||||
@@ -8,6 +8,7 @@
|
||||
"core:window:allow-start-dragging",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"log:default",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted"
|
||||
]
|
||||
|
||||
@@ -7,7 +7,7 @@ pub async fn reveal_in_file_manager(
|
||||
app_handle: tauri::AppHandle,
|
||||
path: 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(|| {
|
||||
format!(
|
||||
"Downloaded file or partial file is missing: {}",
|
||||
@@ -28,7 +28,7 @@ pub async fn open_downloaded_file(
|
||||
app_handle: tauri::AppHandle,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
let path = authorize_download_path(&app_handle, &path, DownloadAsset::Primary)?;
|
||||
let path = authorize_download_path(&app_handle, &path)?;
|
||||
if !path.exists() {
|
||||
return Err(format!("Downloaded file is missing: {}", path.display()));
|
||||
}
|
||||
@@ -41,52 +41,11 @@ pub async fn open_downloaded_file(
|
||||
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(
|
||||
app_handle: &tauri::AppHandle,
|
||||
requested: &str,
|
||||
asset: DownloadAsset,
|
||||
) -> Result<PathBuf, String> {
|
||||
let known_paths = 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)
|
||||
authorize_exact_path(Path::new(requested), &known_download_paths(app_handle)?)
|
||||
}
|
||||
|
||||
fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||
|
||||
@@ -26,7 +26,7 @@ pub enum DownloadCmd {
|
||||
Start(Box<DownloadPayload>),
|
||||
Pause(Uuid),
|
||||
PauseWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
|
||||
Cancel(Uuid),
|
||||
CancelWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
|
||||
CaptureUrls(Vec<String>),
|
||||
FrontendReady(bool),
|
||||
}
|
||||
@@ -338,9 +338,12 @@ async fn run_coordinator(
|
||||
let _ = ack.send(());
|
||||
}
|
||||
}
|
||||
DownloadCmd::Cancel(id) => {
|
||||
DownloadCmd::CancelWithAck(id, ack) => {
|
||||
if let Some(download) = active.remove(&id) {
|
||||
let _ = download.control_tx.send(DownloadControl::Cancel).await;
|
||||
pending_acks.insert(id, ack);
|
||||
} else {
|
||||
let _ = ack.send(());
|
||||
}
|
||||
}
|
||||
DownloadCmd::CaptureUrls(urls) => {
|
||||
|
||||
@@ -9,6 +9,30 @@ struct DownloadOwnershipRecord {
|
||||
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(
|
||||
app_handle: &tauri::AppHandle,
|
||||
destination: &str,
|
||||
@@ -19,10 +43,7 @@ pub fn expected_primary_path(
|
||||
return Err("Path traversal blocked".to_string());
|
||||
}
|
||||
|
||||
let safe_filename = Path::new(&filename.replace('\\', "/"))
|
||||
.file_name()
|
||||
.ok_or_else(|| "Download filename is invalid".to_string())?
|
||||
.to_owned();
|
||||
let safe_filename = canonical_download_filename(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)
|
||||
}
|
||||
|
||||
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> {
|
||||
let mut paths: Vec<PathBuf> = load_records(app_handle)?
|
||||
.into_iter()
|
||||
@@ -168,3 +199,15 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
|
||||
|
||||
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
@@ -8,6 +8,8 @@ use ts_rs::TS;
|
||||
pub enum DownloadStatus {
|
||||
/// Added to the download list but not assigned to a queue or dispatched.
|
||||
Ready,
|
||||
/// Assigned to a queue but intentionally not registered with the backend.
|
||||
Staged,
|
||||
Downloading,
|
||||
/// Post-download media processing such as yt-dlp/ffmpeg merging or
|
||||
/// extraction. The queue permit is still held.
|
||||
@@ -25,6 +27,7 @@ impl DownloadStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ready => "ready",
|
||||
Self::Staged => "staged",
|
||||
Self::Downloading => "downloading",
|
||||
Self::Processing => "processing",
|
||||
Self::Paused => "paused",
|
||||
@@ -100,6 +103,8 @@ pub struct DownloadItem {
|
||||
#[ts(optional)]
|
||||
pub queue_id: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub queue_position: Option<i32>,
|
||||
#[ts(optional)]
|
||||
pub has_been_dispatched: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -110,9 +115,19 @@ pub struct EnqueueResult {
|
||||
pub id: String,
|
||||
pub success: bool,
|
||||
#[ts(optional)]
|
||||
pub filename: Option<String>,
|
||||
#[ts(optional)]
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -170,7 +185,7 @@ pub enum ActiveView {
|
||||
Scheduler,
|
||||
#[serde(rename = "speedLimiter")]
|
||||
SpeedLimiter,
|
||||
Diagnostics,
|
||||
Logs,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
@@ -219,6 +234,7 @@ pub struct SchedulerSettings {
|
||||
pub stop_time: String,
|
||||
pub everyday: bool,
|
||||
pub selected_days: Vec<u32>,
|
||||
pub selected_queue_ids: Vec<String>,
|
||||
pub post_queue_action: PostQueueAction,
|
||||
}
|
||||
|
||||
|
||||
+235
-77
@@ -665,6 +665,14 @@ fn aggregate_media_fraction(
|
||||
}
|
||||
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -676,7 +684,9 @@ async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) {
|
||||
.and_then(|name| name.to_str())
|
||||
.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 {
|
||||
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())
|
||||
}
|
||||
|
||||
// ── get_engine_status: Structured engine diagnostics ──────────────
|
||||
// ── get_engine_status: Structured engine status ──────────────
|
||||
|
||||
async fn run_sidecar_version(
|
||||
app_handle: &tauri::AppHandle,
|
||||
@@ -1915,11 +1925,7 @@ pub(crate) async fn start_media_download_internal(
|
||||
max_tries: Option<i32>,
|
||||
cancel_rx: &mut tokio::sync::watch::Receiver<bool>,
|
||||
) -> Result<(), String> {
|
||||
let safe_filename = std::path::Path::new(&filename.replace('\\', "/"))
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let safe_filename = crate::download_ownership::canonical_download_filename(&filename);
|
||||
|
||||
|
||||
let resolved_dest = resolve_path(&destination, &app_handle);
|
||||
@@ -2413,11 +2419,10 @@ async fn remove_download(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
filepath: Option<String>,
|
||||
delete_assets: bool,
|
||||
) -> Result<(), String> {
|
||||
log::info!("remove_download called for id: {}", id);
|
||||
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||
state.queue_manager.release_registered_id(&id).await;
|
||||
let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?;
|
||||
|
||||
let active_kind = state.queue_manager.active_kind(&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) {
|
||||
state
|
||||
.download_coordinator
|
||||
.send(download::DownloadCmd::Cancel(download_id))
|
||||
.send(download::DownloadCmd::CancelWithAck(download_id, tx))
|
||||
.await?;
|
||||
let _ = tx.send(());
|
||||
} else {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
@@ -2468,41 +2472,61 @@ async fn remove_download(
|
||||
crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused),
|
||||
);
|
||||
|
||||
if let Some(path) = filepath {
|
||||
if !path.is_empty() {
|
||||
let p = std::path::Path::new(&path);
|
||||
if is_safe_path(p, &app_handle) {
|
||||
if p.exists() {
|
||||
let _ = tokio::fs::remove_file(p).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 delete_assets {
|
||||
if let Some(path) = primary_path.as_deref() {
|
||||
remove_download_assets(path, &app_handle).await?;
|
||||
}
|
||||
} else if let Some(path) = primary_path.as_deref() {
|
||||
remove_partial_download_assets(path, &app_handle).await?;
|
||||
}
|
||||
|
||||
if let Some(parent) = p.parent() {
|
||||
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
|
||||
let stem_with_dot = format!("{}.", stem);
|
||||
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() {
|
||||
if file_name.starts_with(&stem_with_dot) &&
|
||||
(file_name.ends_with(".part") || file_name.ends_with(".ytdl") || file_name.ends_with(".aria2")) {
|
||||
let path_to_remove = entry.path();
|
||||
if is_safe_path(&path_to_remove, &app_handle) {
|
||||
let _ = tokio::fs::remove_file(path_to_remove).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::download_ownership::remove(&app_handle, &id)?;
|
||||
state.queue_manager.release_registered_id(&id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_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());
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -2703,17 +2727,43 @@ fn perform_system_action(action: crate::ipc::PostQueueAction) -> Result<(), Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_pending_order(state: tauri::State<'_, AppState>) -> Result<Vec<String>, AppError> {
|
||||
Ok(state.queue_manager.pending_order().await)
|
||||
fn ack_schedule_trigger(
|
||||
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]
|
||||
async fn enqueue_download(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
item: queue::EnqueueItem,
|
||||
) -> Result<String, AppError> {
|
||||
mut item: queue::EnqueueItem,
|
||||
) -> Result<crate::ipc::EnqueueAccepted, AppError> {
|
||||
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(
|
||||
&app_handle,
|
||||
&item.id,
|
||||
@@ -2725,15 +2775,21 @@ async fn enqueue_download(
|
||||
state.queue_manager.release_registered_id(&id).await;
|
||||
return Err(AppError::Internal(e));
|
||||
}
|
||||
Ok(id)
|
||||
Ok(crate::ipc::EnqueueAccepted {
|
||||
id,
|
||||
filename: accepted_filename,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn enqueue_many(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
items: Vec<queue::EnqueueItem>,
|
||||
mut items: Vec<queue::EnqueueItem>,
|
||||
) -> 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 {
|
||||
crate::download_ownership::register_expected(
|
||||
&app_handle,
|
||||
@@ -2759,9 +2815,13 @@ async fn enqueue_many(
|
||||
async fn move_in_queue(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
queue_id: String,
|
||||
direction: crate::ipc::QueueDirection,
|
||||
) -> 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]
|
||||
@@ -3010,26 +3070,120 @@ fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String>
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn export_logs(app_handle: tauri::AppHandle, dest_path: String) -> Result<String, String> {
|
||||
async fn log_files(app_handle: &tauri::AppHandle) -> Result<Vec<std::path::PathBuf>, String> {
|
||||
use tauri::Manager;
|
||||
let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?;
|
||||
let log_file = log_dir.join("firelink.log");
|
||||
let src = if log_file.exists() {
|
||||
log_file
|
||||
} else {
|
||||
let mut found = None;
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if entry.path().extension().is_some_and(|e| e == "log") {
|
||||
found = Some(entry.path());
|
||||
break;
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
if path.is_file()
|
||||
&& path
|
||||
.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().contains(".log"))
|
||||
{
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
found.ok_or_else(|| "No log file found in app log directory".to_string())?
|
||||
};
|
||||
tokio::fs::copy(&src, &dest_path).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
files.sort();
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -3154,7 +3308,7 @@ mod tests {
|
||||
aggregate_media_fraction, build_media_format_options, collect_download_uris,
|
||||
is_excluded_yt_dlp_format, json_lower, media_progress_speed,
|
||||
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 std::time::{Duration, Instant};
|
||||
@@ -3168,6 +3322,16 @@ mod tests {
|
||||
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]
|
||||
fn collects_primary_url_and_unique_mirrors_in_order() {
|
||||
let uris = collect_download_uris(
|
||||
@@ -3802,18 +3966,11 @@ pub fn run() {
|
||||
.targets([
|
||||
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::Webview),
|
||||
])
|
||||
.level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info })
|
||||
.max_file_size(10_000_000)
|
||||
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3))
|
||||
.format(move |out, message, _record| {
|
||||
let msg = message.to_string();
|
||||
if msg.contains("[download]") && msg.contains('%') {
|
||||
return;
|
||||
}
|
||||
out.finish(format_args!("{}\n", msg));
|
||||
})
|
||||
.timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal)
|
||||
.build(),
|
||||
)
|
||||
.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,
|
||||
pause_download, resume_download, fetch_metadata, fetch_media_metadata,
|
||||
update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action,
|
||||
ack_schedule_trigger,
|
||||
request_automation_permission, open_automation_settings,
|
||||
set_keychain_password, get_keychain_password, delete_keychain_password,
|
||||
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,
|
||||
detach_download_for_reconfigure,
|
||||
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::create_category_directories,
|
||||
db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads,
|
||||
db_get_all_queues, db_replace_queues,
|
||||
export_logs
|
||||
read_logs, export_logs
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
+30
-23
@@ -37,6 +37,7 @@ pub enum TaskKind {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QueuedTask {
|
||||
pub id: String,
|
||||
pub queue_id: String,
|
||||
pub kind: TaskKind,
|
||||
pub payload: SpawnPayload,
|
||||
}
|
||||
@@ -158,11 +159,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
}
|
||||
|
||||
/// 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
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|task| queue_id.is_none_or(|queue_id| task.queue_id == queue_id))
|
||||
.map(|t| t.id.clone())
|
||||
.collect()
|
||||
}
|
||||
@@ -721,26 +723,38 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
pub async fn move_in_queue(
|
||||
&self,
|
||||
id: &str,
|
||||
queue_id: &str,
|
||||
direction: QueueDirection,
|
||||
) -> Vec<String> {
|
||||
let mut pending = self.pending.lock().await;
|
||||
let pos = pending.iter().position(|t| t.id == id);
|
||||
if let Some(pos) = pos {
|
||||
let queue_positions = pending
|
||||
.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 {
|
||||
QueueDirection::Up => pos.checked_sub(1),
|
||||
QueueDirection::Up => queue_pos.checked_sub(1),
|
||||
QueueDirection::Down => {
|
||||
if pos + 1 < pending.len() {
|
||||
Some(pos + 1)
|
||||
if queue_pos + 1 < queue_positions.len() {
|
||||
Some(queue_pos + 1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
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).
|
||||
@@ -765,10 +779,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
|
||||
for task in tasks {
|
||||
let id = task.id.clone();
|
||||
let filename = task.payload.filename.clone();
|
||||
if registered.contains(&id) {
|
||||
results.push(crate::ipc::EnqueueResult {
|
||||
id: id.clone(),
|
||||
success: false,
|
||||
filename: None,
|
||||
error: Some("Duplicate task".to_string()),
|
||||
});
|
||||
continue;
|
||||
@@ -779,6 +795,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
results.push(crate::ipc::EnqueueResult {
|
||||
id,
|
||||
success: true,
|
||||
filename: Some(filename),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
@@ -814,11 +831,7 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
"dir".to_string(),
|
||||
serde_json::json!(resolved_dest.to_string_lossy().to_string()),
|
||||
);
|
||||
let safe_filename = std::path::Path::new(&payload.filename.replace('\\', "/"))
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let safe_filename = crate::download_ownership::canonical_download_filename(&payload.filename);
|
||||
options.insert("out".to_string(), serde_json::json!(safe_filename));
|
||||
let conn = payload.connections.unwrap_or(1);
|
||||
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);
|
||||
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 safe_filename = std::path::Path::new(&payload.filename.replace('\\', "/"))
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let safe_filename = crate::download_ownership::canonical_download_filename(&payload.filename);
|
||||
state
|
||||
.download_coordinator
|
||||
.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 mt = payload.max_tries.unwrap_or(1).max(1) as u32;
|
||||
let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle);
|
||||
let safe_filename = std::path::Path::new(&payload.filename.replace('\\', "/"))
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let safe_filename = crate::download_ownership::canonical_download_filename(&payload.filename);
|
||||
let output_path = resolved_dest.join(safe_filename);
|
||||
let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, &output_path);
|
||||
state
|
||||
@@ -1002,6 +1007,7 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct EnqueueItem {
|
||||
pub id: String,
|
||||
pub queue_id: String,
|
||||
pub url: String,
|
||||
pub destination: String,
|
||||
pub filename: String,
|
||||
@@ -1032,6 +1038,7 @@ impl EnqueueItem {
|
||||
let id = self.id.clone();
|
||||
QueuedTask {
|
||||
id,
|
||||
queue_id: self.queue_id,
|
||||
kind,
|
||||
payload: SpawnPayload {
|
||||
url: self.url,
|
||||
|
||||
+51
-19
@@ -1,10 +1,19 @@
|
||||
use chrono::{Datelike, Local};
|
||||
use chrono::{Datelike, Local, Timelike};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
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) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||
let mut last_emit: HashMap<&'static str, std::time::Instant> = HashMap::new();
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
@@ -15,7 +24,7 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
|
||||
}
|
||||
|
||||
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 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 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
|
||||
&& settings.scheduler_last_start_key != trigger_key
|
||||
if start_minute.is_some_and(|start| current_minute >= start)
|
||||
&& 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!({
|
||||
"action": "start",
|
||||
"key": key
|
||||
"key": start_key
|
||||
}));
|
||||
last_emit.insert("start", std::time::Instant::now());
|
||||
}
|
||||
|
||||
if scheduler.stop_time_enabled
|
||||
&& scheduler.stop_time == current_time
|
||||
&& settings.scheduler_last_stop_key != trigger_key
|
||||
&& stop_minute.is_some_and(|stop| current_minute >= stop)
|
||||
&& 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!({
|
||||
"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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +243,7 @@ fn default_settings() -> PersistedSettings {
|
||||
stop_time: "08:00".to_string(),
|
||||
everyday: true,
|
||||
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,
|
||||
},
|
||||
scheduler_last_start_key: String::new(),
|
||||
@@ -299,6 +300,10 @@ mod tests {
|
||||
assert!(settings.scheduler.enabled);
|
||||
assert_eq!(settings.scheduler.start_time, "06:30");
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -438,7 +438,12 @@ async fn cancel_removes_partial_file_without_terminal_success_event() {
|
||||
.await
|
||||
.unwrap();
|
||||
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 {
|
||||
while output_path.exists() {
|
||||
|
||||
@@ -58,6 +58,7 @@ fn make_manager(capacity: usize) -> (QueueManager<tauri::test::MockRuntime>, Arc
|
||||
fn sample_task(id: &str) -> QueuedTask {
|
||||
QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Native,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
@@ -66,9 +67,9 @@ fn sample_task(id: &str) -> QueuedTask {
|
||||
#[tokio::test]
|
||||
async fn push_appends_to_pending_and_emits_queued() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
mgr.push(sample_task("a")).await;
|
||||
mgr.push(sample_task("b")).await;
|
||||
let order = mgr.pending_order().await;
|
||||
mgr.push(sample_task("a")).await.unwrap();
|
||||
mgr.push(sample_task("b")).await.unwrap();
|
||||
let order = mgr.pending_order(None).await;
|
||||
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]
|
||||
async fn push_then_pop_front_drains_fifo() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
mgr.push(sample_task("a")).await;
|
||||
mgr.push(sample_task("b")).await;
|
||||
mgr.push(sample_task("a")).await.unwrap();
|
||||
mgr.push(sample_task("b")).await.unwrap();
|
||||
let first = mgr.pop_front().await.expect("some task");
|
||||
let second = mgr.pop_front().await.expect("some task");
|
||||
assert_eq!(first.id, "a");
|
||||
@@ -179,7 +180,7 @@ async fn grow_releases_immediately_and_dispatches_waiting_tasks() {
|
||||
let mgr_arc = Arc::new(mgr);
|
||||
|
||||
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 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);
|
||||
|
||||
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 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 {
|
||||
QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Aria2,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
@@ -247,6 +249,7 @@ fn aria2_task(id: &str) -> QueuedTask {
|
||||
fn media_task(id: &str) -> QueuedTask {
|
||||
QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Media,
|
||||
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() {
|
||||
let (manager, event_rx) = make_media_manager(Err("terminal media failure".to_string()));
|
||||
let manager = Arc::new(manager);
|
||||
manager.push(media_task("media-failed")).await;
|
||||
manager.push(media_task("media-failed")).await.unwrap();
|
||||
let dispatcher = {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||
@@ -325,7 +328,7 @@ async fn media_cancellation_does_not_emit_completed() {
|
||||
let (manager, event_rx) =
|
||||
make_media_manager(Err(MEDIA_RUN_CANCELLED.to_string()));
|
||||
let manager = Arc::new(manager);
|
||||
manager.push(media_task("media-cancelled")).await;
|
||||
manager.push(media_task("media-cancelled")).await.unwrap();
|
||||
let dispatcher = {
|
||||
let manager = Arc::clone(&manager);
|
||||
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() {
|
||||
let (mgr, spawner) = make_manager(1);
|
||||
let mgr_arc = Arc::new(mgr);
|
||||
mgr_arc.push(aria2_task("a")).await;
|
||||
mgr_arc.push(aria2_task("a")).await.unwrap();
|
||||
let handle = {
|
||||
let mgr_clone = Arc::clone(&mgr_arc);
|
||||
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_arc = Arc::new(mgr);
|
||||
mgr_arc.push(aria2_task("a")).await;
|
||||
mgr_arc.push(aria2_task("a")).await.unwrap();
|
||||
let handle = {
|
||||
let mgr_clone = Arc::clone(&mgr_arc);
|
||||
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);
|
||||
|
||||
// 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;
|
||||
mgr_arc.release_permit("b").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_arc = Arc::new(mgr);
|
||||
mgr_arc.push(sample_task("a")).await;
|
||||
mgr_arc.push(sample_task("b")).await;
|
||||
mgr_arc.push(sample_task("c")).await;
|
||||
mgr_arc.push(sample_task("a")).await.unwrap();
|
||||
mgr_arc.push(sample_task("b")).await.unwrap();
|
||||
mgr_arc.push(sample_task("c")).await.unwrap();
|
||||
|
||||
mgr_arc.move_in_queue("c", QueueDirection::Down).await;
|
||||
assert_eq!(mgr_arc.pending_order().await, vec!["a", "b", "c"]);
|
||||
mgr_arc.move_in_queue("c", "main", QueueDirection::Down).await;
|
||||
assert_eq!(mgr_arc.pending_order(None).await, vec!["a", "b", "c"]);
|
||||
|
||||
mgr_arc.move_in_queue("c", QueueDirection::Up).await;
|
||||
assert_eq!(mgr_arc.pending_order().await, vec!["a", "c", "b"]);
|
||||
mgr_arc.move_in_queue("c", "main", QueueDirection::Up).await;
|
||||
assert_eq!(mgr_arc.pending_order(None).await, vec!["a", "c", "b"]);
|
||||
|
||||
mgr_arc.move_in_queue("a", QueueDirection::Down).await;
|
||||
assert_eq!(mgr_arc.pending_order().await, vec!["c", "a", "b"]);
|
||||
mgr_arc.move_in_queue("a", "main", QueueDirection::Down).await;
|
||||
assert_eq!(mgr_arc.pending_order(None).await, vec!["c", "a", "b"]);
|
||||
|
||||
mgr_arc.move_in_queue("c", QueueDirection::Up).await;
|
||||
assert_eq!(mgr_arc.pending_order().await, vec!["c", "a", "b"]);
|
||||
mgr_arc.move_in_queue("c", "main", QueueDirection::Up).await;
|
||||
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]
|
||||
@@ -455,7 +480,7 @@ async fn notify_fires_on_push_and_release() {
|
||||
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(
|
||||
Duration::from_millis(150),
|
||||
async {
|
||||
|
||||
+53
-15
@@ -14,11 +14,20 @@ import { useSettingsStore } from "./store/useSettingsStore";
|
||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
import SchedulerView from "./components/SchedulerView";
|
||||
import SpeedLimiterView from "./components/SpeedLimiterView";
|
||||
import DiagnosticsView from "./components/DiagnosticsView";
|
||||
import LogsView from "./components/LogsView";
|
||||
import { useToast } from "./contexts/ToastContext";
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
|
||||
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() {
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
@@ -40,9 +49,12 @@ function App() {
|
||||
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
||||
const downloads = useDownloadStore(state => state.downloads);
|
||||
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 schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
||||
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
||||
const previousSpeedLimit = useRef<string | null>(null);
|
||||
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||
@@ -218,15 +230,28 @@ function App() {
|
||||
useEffect(() => {
|
||||
const unlisten = listen('schedule-trigger', async (event) => {
|
||||
const state = useSettingsStore.getState();
|
||||
const payload = event.payload as any;
|
||||
if (payload.action === 'start') {
|
||||
state.setSchedulerLastStartKey(payload.key);
|
||||
const started = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
|
||||
state.setSchedulerRunning(started > 0);
|
||||
} else if (payload.action === 'stop') {
|
||||
state.setSchedulerLastStopKey(payload.key);
|
||||
await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
|
||||
state.setSchedulerRunning(false);
|
||||
const payload = event.payload;
|
||||
if (processingScheduleKeys.has(payload.key)) return;
|
||||
processingScheduleKeys.add(payload.key);
|
||||
try {
|
||||
if (payload.action === 'start') {
|
||||
const startedResults = await Promise.all(
|
||||
getScheduledQueueIds().map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
);
|
||||
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(() => {
|
||||
if (!schedulerRunning) return;
|
||||
if (schedulerActiveDownloadIds.length === 0) return;
|
||||
const scheduledIds = new Set(schedulerActiveDownloadIds);
|
||||
const hasPendingScheduledWork = downloads.some(download =>
|
||||
isActiveDownloadStatus(download.status)
|
||||
scheduledIds.has(download.id) && isActiveDownloadStatus(download.status)
|
||||
);
|
||||
if (hasPendingScheduledWork) return;
|
||||
|
||||
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);
|
||||
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 => {
|
||||
console.error('Scheduled post action failed:', error);
|
||||
});
|
||||
}
|
||||
}, [downloads, schedulerRunning]);
|
||||
}, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const initNotifications = async () => {
|
||||
@@ -395,7 +433,7 @@ function App() {
|
||||
{activeView === 'settings' && <SettingsView />}
|
||||
{activeView === 'scheduler' && <SchedulerView />}
|
||||
{activeView === 'speedLimiter' && <SpeedLimiterView />}
|
||||
{activeView === 'diagnostics' && <DiagnosticsView />}
|
||||
{activeView === 'logs' && <LogsView />}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
|
||||
@@ -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">
|
||||
<h1 className="text-xl font-semibold">Firelink could not display this window.</h1>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// 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";
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
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,3 +1,3 @@
|
||||
// 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";
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
// 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,3 +1,3 @@
|
||||
// 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,4 +1,4 @@
|
||||
// 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";
|
||||
|
||||
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, };
|
||||
|
||||
@@ -9,7 +9,7 @@ import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database,
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
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 {
|
||||
resolveCategoryDestination,
|
||||
@@ -72,8 +72,10 @@ export const AddDownloadsModal = () => {
|
||||
const [showingDuplicates, setShowingDuplicates] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
|
||||
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
|
||||
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<number, string>>({});
|
||||
const [resolvedLocation, setResolvedLocation] = useState('');
|
||||
const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const actionMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Right Form
|
||||
@@ -104,7 +106,10 @@ export const AddDownloadsModal = () => {
|
||||
setParsedItems([]);
|
||||
setSelectedItemIndex(null);
|
||||
setPendingUseSharedDestination(false);
|
||||
setPendingDestinationOverrides({});
|
||||
setConnections(perServerConnections);
|
||||
setSpeedLimitEnabled(false);
|
||||
setSpeedLimit('1024');
|
||||
setUseAuth(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
@@ -119,6 +124,7 @@ export const AddDownloadsModal = () => {
|
||||
setCookies(pendingAddCookies);
|
||||
setMirrors('');
|
||||
setIsQueueMenuOpen(false);
|
||||
setIsSubmitting(false);
|
||||
} else {
|
||||
setUrls('');
|
||||
}
|
||||
@@ -211,8 +217,8 @@ export const AddDownloadsModal = () => {
|
||||
const mediaData = await fetchMediaMetadataDeduped({
|
||||
url,
|
||||
cookieBrowser: browserArg,
|
||||
username: login?.username || null,
|
||||
password: keychainPassword
|
||||
username: useAuth ? username.trim() || null : login?.username || null,
|
||||
password: useAuth ? password || null : keychainPassword
|
||||
});
|
||||
if (mediaData && mediaData.formats.length > 0) {
|
||||
const mappedFormats = mediaData.formats.map(f => {
|
||||
@@ -235,7 +241,7 @@ export const AddDownloadsModal = () => {
|
||||
});
|
||||
updatedItems[i] = {
|
||||
url,
|
||||
file: `${mediaData.title}.${mediaData.formats[0].ext}`,
|
||||
file: canonicalizeDownloadFileName(`${mediaData.title}.${mediaData.formats[0].ext}`),
|
||||
size: mappedFormats[0].detail,
|
||||
sizeBytes: mappedFormats[0].bytes,
|
||||
status: 'Ready',
|
||||
@@ -260,12 +266,14 @@ export const AddDownloadsModal = () => {
|
||||
const meta = await invoke('fetch_metadata', {
|
||||
url,
|
||||
userAgent: settingsStore.customUserAgent || null,
|
||||
username: login?.username || null,
|
||||
password: keychainPassword
|
||||
username: useAuth ? username.trim() || null : login?.username || null,
|
||||
password: useAuth ? password || null : keychainPassword
|
||||
});
|
||||
updatedItems[i] = {
|
||||
url: meta.url || url,
|
||||
file: lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename,
|
||||
file: canonicalizeDownloadFileName(
|
||||
lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename
|
||||
),
|
||||
size: meta.size,
|
||||
sizeBytes: meta.size_bytes,
|
||||
status: 'Ready'
|
||||
@@ -301,7 +309,15 @@ export const AddDownloadsModal = () => {
|
||||
active = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [urls, pendingAddFilename, isSaveLocationManual, metadataRefreshNonce]);
|
||||
}, [
|
||||
urls,
|
||||
pendingAddFilename,
|
||||
isSaveLocationManual,
|
||||
metadataRefreshNonce,
|
||||
useAuth,
|
||||
username,
|
||||
password
|
||||
]);
|
||||
|
||||
if (!isAddModalOpen) return null;
|
||||
|
||||
@@ -327,25 +343,41 @@ export const AddDownloadsModal = () => {
|
||||
};
|
||||
|
||||
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 useSharedDestination = isSaveLocationManual;
|
||||
const destinationOverrides: Record<number, string> = {};
|
||||
const settings = useSettingsStore.getState();
|
||||
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: finalLocation.startsWith('~') ? undefined : finalLocation
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
finalLocation = selected;
|
||||
useSharedDestination = true;
|
||||
setIsSaveLocationManual(true);
|
||||
} else {
|
||||
return; // Cancelled
|
||||
for (const [index, item] of parsedItems.entries()) {
|
||||
try {
|
||||
const suggestedLocation = isSaveLocationManual
|
||||
? finalLocation
|
||||
: await categoryLocationForFile(item.file);
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: `Choose a folder for ${item.file}`,
|
||||
defaultPath: suggestedLocation.startsWith('~') ? undefined : suggestedLocation
|
||||
});
|
||||
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++) {
|
||||
const item = parsedItems[i];
|
||||
let finalFile = item.file;
|
||||
let finalFile = canonicalizeDownloadFileName(item.file);
|
||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
||||
const selectedFormat = item.formats[item.selectedFormat];
|
||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
@@ -363,7 +395,7 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
const itemLocation = useSharedDestination
|
||||
? 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');
|
||||
if (isUrlDupe) {
|
||||
@@ -400,14 +432,26 @@ export const AddDownloadsModal = () => {
|
||||
setConflicts(newConflicts);
|
||||
setPendingAction(action);
|
||||
setPendingUseSharedDestination(useSharedDestination);
|
||||
setPendingDestinationOverrides(destinationOverrides);
|
||||
setShowingDuplicates(true);
|
||||
setIsSubmitting(false);
|
||||
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];
|
||||
|
||||
if (resolutions) {
|
||||
@@ -420,7 +464,7 @@ export const AddDownloadsModal = () => {
|
||||
if (res.resolution === 'skip') {
|
||||
itemsToAdd[idx] = null;
|
||||
} else if (res.resolution === 'rename') {
|
||||
let finalFile = item.file;
|
||||
let finalFile = canonicalizeDownloadFileName(item.file);
|
||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
||||
const selectedFormat = item.formats[item.selectedFormat];
|
||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
@@ -428,7 +472,7 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
const itemLocation = useSharedDestination
|
||||
? finalLocation
|
||||
: await categoryLocationForFile(finalFile);
|
||||
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
|
||||
|
||||
let count = 1;
|
||||
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
@@ -471,7 +515,7 @@ export const AddDownloadsModal = () => {
|
||||
itemsToAdd[idx] = null;
|
||||
continue;
|
||||
}
|
||||
let finalFile = item.file;
|
||||
let finalFile = canonicalizeDownloadFileName(item.file);
|
||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
||||
const selectedFormat = item.formats[item.selectedFormat];
|
||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
@@ -479,7 +523,7 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
const itemLocation = useSharedDestination
|
||||
? finalLocation
|
||||
: await categoryLocationForFile(finalFile);
|
||||
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
|
||||
const fullPath = await resolveDownloadFilePath(itemLocation, finalFile);
|
||||
|
||||
const store = useDownloadStore.getState();
|
||||
@@ -510,14 +554,14 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedItems = itemsToAdd.filter((item): item is ParsedDownloadItem => item !== null);
|
||||
let addedCount = 0;
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const item of resolvedItems) {
|
||||
for (const [itemIndex, item] of itemsToAdd.entries()) {
|
||||
if (!item) continue;
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
let finalFile = item.file;
|
||||
let finalFile = canonicalizeDownloadFileName(item.file);
|
||||
let formatSelector = undefined;
|
||||
|
||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
||||
@@ -546,7 +590,9 @@ export const AddDownloadsModal = () => {
|
||||
: undefined,
|
||||
cookies: cookies.trim() || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
destination: useSharedDestination ? finalLocation : undefined,
|
||||
destination: useSharedDestination
|
||||
? finalLocation
|
||||
: destinationOverrides[itemIndex],
|
||||
isMedia: item.isMedia,
|
||||
mediaFormatSelector: formatSelector,
|
||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
|
||||
@@ -598,16 +644,20 @@ export const AddDownloadsModal = () => {
|
||||
selectedItem.size = format.detail || 'Unknown';
|
||||
selectedItem.sizeBytes = format.bytes || 0;
|
||||
const baseName = selectedItem.file.substring(0, selectedItem.file.lastIndexOf('.')) || selectedItem.file;
|
||||
selectedItem.file = `${baseName}.${format.ext}`;
|
||||
selectedItem.file = canonicalizeDownloadFileName(`${baseName}.${format.ext}`);
|
||||
setParsedItems(newItems);
|
||||
};
|
||||
|
||||
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
|
||||
? (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).toFixed(2)} GB`)
|
||||
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}`
|
||||
: 'Unknown';
|
||||
const canSubmit = parsedItems.length > 0 && parsedItems.every(item => item.status === 'Ready');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -616,14 +666,22 @@ export const AddDownloadsModal = () => {
|
||||
conflicts={conflicts}
|
||||
onConfirm={(resolutions) => {
|
||||
setShowingDuplicates(false);
|
||||
void executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions)
|
||||
setIsSubmitting(true);
|
||||
void executeAddDownloads(
|
||||
pendingAction,
|
||||
resolvedLocation,
|
||||
pendingUseSharedDestination,
|
||||
resolutions,
|
||||
pendingDestinationOverrides
|
||||
)
|
||||
.catch(error => {
|
||||
addToast({
|
||||
message: `Could not resolve duplicate downloads: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
})
|
||||
.finally(() => setIsSubmitting(false));
|
||||
}}
|
||||
onCancel={() => setShowingDuplicates(false)}
|
||||
/>
|
||||
@@ -652,7 +710,9 @@ export const AddDownloadsModal = () => {
|
||||
onChange={(e) => setUrls(e.target.value)}
|
||||
/>
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => setMetadataRefreshNonce(value => value + 1)}
|
||||
@@ -836,7 +896,7 @@ export const AddDownloadsModal = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-text-secondary font-medium">Connections per File</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -924,7 +984,11 @@ export const AddDownloadsModal = () => {
|
||||
{/* Footer */}
|
||||
<div className="add-download-footer p-4 flex items-center shrink-0">
|
||||
<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 className="flex gap-2.5">
|
||||
<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">
|
||||
<button
|
||||
onClick={() => handleAction({ type: 'start-now' })}
|
||||
disabled={parsedItems.length === 0}
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
className="add-download-button add-download-button-primary px-5 text-xs"
|
||||
>
|
||||
<Play size={12} fill="currentColor" /> Start Downloads
|
||||
@@ -942,7 +1006,7 @@ export const AddDownloadsModal = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsQueueMenuOpen(open => !open)}
|
||||
disabled={parsedItems.length === 0}
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
className="add-download-button add-download-button-secondary px-4 text-xs"
|
||||
aria-label="Add to queue"
|
||||
aria-haspopup="menu"
|
||||
|
||||
@@ -31,9 +31,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
onClick,
|
||||
}) => {
|
||||
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 queueIndex = pendingOrder.indexOf(downloadId);
|
||||
const queueIndex = queueItems.indexOf(downloadId);
|
||||
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const statusTextRef = useRef<HTMLSpanElement>(null);
|
||||
@@ -106,7 +117,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
className={`download-progress-fill ${
|
||||
download.status === 'paused' ? 'paused' :
|
||||
download.status === 'processing' ? 'processing' :
|
||||
download.status === 'queued' ? 'queued' :
|
||||
download.status === 'queued' || download.status === 'staged' ? 'queued' :
|
||||
download.status === 'retrying' ? 'retrying' : ''
|
||||
}`}
|
||||
style={{ width: `${(download.fraction || 0) * 100}%` }}
|
||||
@@ -115,8 +126,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
<span
|
||||
ref={statusTextRef}
|
||||
title={
|
||||
download.status === 'queued' && queueIndex !== -1
|
||||
? `Queued #${queueIndex + 1}`
|
||||
(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
||||
? `${download.status === 'staged' ? 'In queue' : 'Queued'} #${queueIndex + 1}`
|
||||
: download.status === 'downloading'
|
||||
? `${((download.fraction || 0) * 100).toFixed(0)}%`
|
||||
: download.status === 'processing'
|
||||
@@ -128,14 +139,16 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
download.status === 'failed' ? 'download-status-failed' :
|
||||
download.status === 'processing' ? 'download-status-processing' :
|
||||
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 === 'queued' && queueIndex !== -1 ? (
|
||||
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? (
|
||||
<>
|
||||
<Clock size={12} className="animate-pulse shrink-0" />
|
||||
<span className="truncate">Queued #{queueIndex + 1}</span>
|
||||
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse shrink-0' : 'shrink-0'} />
|
||||
<span className="truncate">
|
||||
{download.status === 'staged' ? 'In queue' : 'Queued'} #{queueIndex + 1}
|
||||
</span>
|
||||
</>
|
||||
) : download.status === 'downloading' ? (
|
||||
`${((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"
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{download.status === 'queued' && queueIndex !== -1 && (
|
||||
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => moveInQueue(download.id, 'up')}
|
||||
@@ -193,7 +206,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
title="Move Down"
|
||||
>
|
||||
|
||||
@@ -338,7 +338,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
</div>
|
||||
|
||||
<div className="download-table-body">
|
||||
<div className="h-full overflow-auto flex flex-col">
|
||||
<div className="download-table-list">
|
||||
{filteredDownloads.map((d, index) => (
|
||||
<DownloadItemComponent
|
||||
key={d.id}
|
||||
@@ -354,16 +354,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
onClick={handleItemClick}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, 50 - filteredDownloads.length) }).map((_, i) => {
|
||||
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 className="flex-1 min-h-0 bg-transparent pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,7 +402,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{queues.map(q => (
|
||||
<button key={q.id} onClick={() => {
|
||||
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]">
|
||||
{q.name}
|
||||
</button>
|
||||
@@ -526,7 +519,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{queues.map(q => (
|
||||
<button key={q.id} onClick={() => {
|
||||
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]">
|
||||
{q.name}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { attachLogger } from '@tauri-apps/plugin-log';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { FileDown, Trash2, Terminal, Filter } from 'lucide-react';
|
||||
@@ -11,39 +10,46 @@ interface LogEntry {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const getLevelStr = (level: number): LogEntry['level'] => {
|
||||
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() {
|
||||
export default function LogsView() {
|
||||
const { addToast } = useToast();
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const rawLineCountRef = useRef(0);
|
||||
const clearedThroughRef = useRef(0);
|
||||
const lastSnapshotRef = useRef('');
|
||||
const MAX_LOG_LINES = 2000;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const unlistenPromise = attachLogger((logRecord) => {
|
||||
if (!active) return;
|
||||
const level = getLevelStr(logRecord.level);
|
||||
const message = logRecord.message;
|
||||
if (message.includes('[download]') && message.includes('%')) return;
|
||||
setLogs(prev => {
|
||||
const next = [...prev, { level, message }];
|
||||
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
|
||||
});
|
||||
});
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const lines = await invoke('read_logs', { limit: MAX_LOG_LINES });
|
||||
if (!active) return;
|
||||
if (lines.length < clearedThroughRef.current) {
|
||||
clearedThroughRef.current = 0;
|
||||
}
|
||||
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 () => {
|
||||
active = false;
|
||||
void unlistenPromise.then(unlisten => unlisten()).catch(() => undefined);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -56,19 +62,22 @@ export default function DiagnosticsView() {
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const path = await save({
|
||||
defaultPath: 'Firelink-Diagnostics.log',
|
||||
defaultPath: 'Firelink-Support-Logs.log',
|
||||
filters: [{ name: 'Log Files', extensions: ['log'] }],
|
||||
});
|
||||
if (!path) return;
|
||||
await invoke('export_logs', { destPath: path });
|
||||
addToast({ message: 'Diagnostics exported', variant: 'success' });
|
||||
addToast({ message: 'Support logs exported', variant: 'success' });
|
||||
} catch (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) => {
|
||||
switch (level) {
|
||||
@@ -80,14 +89,14 @@ export default function DiagnosticsView() {
|
||||
};
|
||||
|
||||
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 />
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -110,7 +119,7 @@ export default function DiagnosticsView() {
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="app-icon-button"
|
||||
title="Clear console"
|
||||
title="Clear displayed logs"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -126,9 +135,9 @@ export default function DiagnosticsView() {
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<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) => (
|
||||
<div key={i} className={`log-line ${severityClass(entry.level)}`}>
|
||||
@@ -26,6 +26,11 @@ const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[
|
||||
{ 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 {
|
||||
if (!settings.enabled) return 'Scheduler is disabled';
|
||||
|
||||
@@ -55,6 +60,7 @@ export default function SchedulerView() {
|
||||
const savedSettings = useSettingsStore(state => state.scheduler);
|
||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||
const setScheduler = useSettingsStore(state => state.setScheduler);
|
||||
const queues = useDownloadStore(state => state.queues);
|
||||
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
|
||||
const { addToast } = useToast();
|
||||
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 = () => {
|
||||
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 = {
|
||||
...draft,
|
||||
selectedDays: draft.everyday || draft.selectedDays.length > 0
|
||||
? draft.selectedDays
|
||||
: savedSettings.selectedDays
|
||||
selectedDays: draft.selectedDays,
|
||||
selectedQueueIds: effectiveSelectedQueueIds
|
||||
};
|
||||
setScheduler(normalized);
|
||||
setDraft(normalized);
|
||||
@@ -94,18 +129,27 @@ export default function SchedulerView() {
|
||||
};
|
||||
|
||||
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) {
|
||||
useSettingsStore.getState().setSchedulerRunning(true);
|
||||
useSettingsStore.getState().setSchedulerActiveDownloadIds(acceptedIds);
|
||||
addToast({ message: `Started ${count} download${count === 1 ? '' : 's'}`, variant: 'success' });
|
||||
} 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 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().setSchedulerActiveDownloadIds([]);
|
||||
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" />
|
||||
</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" />
|
||||
<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">
|
||||
<List size={17} className="text-accent" /> Queues to Schedule
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-[13px] text-text-primary">
|
||||
<input type="checkbox" checked readOnly disabled={!draft.enabled} className="accent-accent" />
|
||||
Main Queue
|
||||
<span className="text-[11px] text-text-muted">All paused and failed downloads</span>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{queues.map(queue => {
|
||||
const selected = draft.selectedQueueIds.includes(queue.id);
|
||||
return (
|
||||
<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 className="app-card p-5">
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function SettingsView() {
|
||||
const settings = useSettingsStore();
|
||||
const activeTab = settings.activeSettingsTab;
|
||||
|
||||
// Local state for engine diagnostics
|
||||
// Local state for engine status
|
||||
const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null);
|
||||
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
|
||||
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
|
||||
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
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 { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
|
||||
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [isAddingQueue, setIsAddingQueue] = useState(false);
|
||||
const [newQueueName, setNewQueueName] = useState('');
|
||||
@@ -236,7 +238,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
<div className="sidebar-section-label">Tools</div>
|
||||
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
|
||||
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
|
||||
<ToolItem icon={Bug} label="Diagnostics" view="diagnostics" />
|
||||
<ToolItem icon={Bug} label="Logs" view="logs" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -294,7 +296,17 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
{!queues.find(q => q.id === contextMenu.id)?.isMain && (
|
||||
<button
|
||||
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" />
|
||||
Delete Queue
|
||||
|
||||
+26
-16
@@ -1427,6 +1427,8 @@
|
||||
|
||||
.download-table-scroll {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
@@ -1475,10 +1477,18 @@
|
||||
}
|
||||
|
||||
.download-table-body {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.download-table-list {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.download-row {
|
||||
height: 32px;
|
||||
display: grid;
|
||||
@@ -1727,20 +1737,20 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
}
|
||||
}
|
||||
|
||||
/* Diagnostics Console */
|
||||
.diagnostics-toolbar {
|
||||
/* Logs Console */
|
||||
.logs-toolbar {
|
||||
height: 42px;
|
||||
border-bottom: 1px solid hsl(var(--border-color));
|
||||
background: hsl(var(--statusbar-bg));
|
||||
}
|
||||
|
||||
.diagnostics-console {
|
||||
.logs-console {
|
||||
background: hsl(0 0% 7%);
|
||||
color: hsl(0 0% 82%);
|
||||
font-family: "SF Mono", Monaco, "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.diagnostics-console .log-line {
|
||||
.logs-console .log-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 4px;
|
||||
@@ -1751,39 +1761,39 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.diagnostics-console .log-level-tag {
|
||||
.logs-console .log-level-tag {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
min-width: 52px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.diagnostics-console .log-message {
|
||||
.logs-console .log-message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.diagnostics-console .log-error .log-level-tag,
|
||||
.diagnostics-console .log-error .log-message {
|
||||
.logs-console .log-error .log-level-tag,
|
||||
.logs-console .log-error .log-message {
|
||||
color: hsl(0 72% 58%);
|
||||
}
|
||||
|
||||
.diagnostics-console .log-warn .log-level-tag,
|
||||
.diagnostics-console .log-warn .log-message {
|
||||
.logs-console .log-warn .log-level-tag,
|
||||
.logs-console .log-warn .log-message {
|
||||
color: hsl(45 100% 50%);
|
||||
}
|
||||
|
||||
.diagnostics-console .log-info .log-level-tag,
|
||||
.diagnostics-console .log-info .log-message {
|
||||
.logs-console .log-info .log-level-tag,
|
||||
.logs-console .log-info .log-message {
|
||||
color: hsl(0 0% 75%);
|
||||
}
|
||||
|
||||
.diagnostics-console .log-debug .log-level-tag,
|
||||
.diagnostics-console .log-debug .log-message {
|
||||
.logs-console .log-debug .log-level-tag,
|
||||
.logs-console .log-debug .log-message {
|
||||
color: hsl(0 0% 45%);
|
||||
}
|
||||
|
||||
.diagnostics-console .log-line:hover {
|
||||
.logs-console .log-line:hover {
|
||||
background: hsl(0 0% 100% / 0.04);
|
||||
}
|
||||
|
||||
|
||||
+9
-7
@@ -12,6 +12,7 @@ import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
|
||||
import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
|
||||
import type { EnqueueItem } from './bindings/EnqueueItem';
|
||||
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
@@ -28,14 +29,14 @@ type CommandMap = {
|
||||
get_deno_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
reveal_in_file_manager: { 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 };
|
||||
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 };
|
||||
update_dock_badge: { args: { count: number }; result: void };
|
||||
set_prevent_sleep: { args: { prevent: boolean }; 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_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||
request_automation_permission: { args: undefined; result: void };
|
||||
@@ -67,10 +68,11 @@ type CommandMap = {
|
||||
result: void;
|
||||
};
|
||||
export_logs: { args: { destPath: string }; result: string };
|
||||
get_pending_order: { args: undefined; result: string[] };
|
||||
enqueue_download: { args: { item: EnqueueItem }; result: string };
|
||||
read_logs: { args: { limit: number }; 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[] };
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -83,13 +85,13 @@ export function invokeCommand<K extends CommandName>(
|
||||
...args: CommandArgs<K> extends undefined ? [] : [args: CommandArgs<K>]
|
||||
): Promise<CommandResult<K>> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
type EventMap = {
|
||||
'schedule-trigger': 'start' | 'stop';
|
||||
'schedule-trigger': { action: 'start' | 'stop'; key: string };
|
||||
'download-progress': DownloadProgressEvent;
|
||||
'download-state': DownloadStateEvent;
|
||||
'download-complete': string;
|
||||
|
||||
@@ -4,6 +4,32 @@ import "./index.css";
|
||||
import App from "./App";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
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");
|
||||
if (rootElement) {
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('useDownloadStore', () => {
|
||||
});
|
||||
|
||||
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 enqueues = calls.filter(c => c[0] === 'enqueue_download');
|
||||
@@ -79,6 +79,34 @@ describe('useDownloadStore', () => {
|
||||
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 () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
@@ -113,9 +141,9 @@ describe('useDownloadStore', () => {
|
||||
}, { type: 'add-to-queue', queueId: 'queue-b' });
|
||||
|
||||
const item = useDownloadStore.getState().downloads[0];
|
||||
expect(item.status).toBe('queued');
|
||||
expect(item.status).toBe('staged');
|
||||
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());
|
||||
});
|
||||
|
||||
@@ -207,7 +235,7 @@ describe('useDownloadStore', () => {
|
||||
).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({
|
||||
downloads: [
|
||||
{ id: 'ready', status: 'ready', queueId: 'old' },
|
||||
@@ -215,12 +243,50 @@ describe('useDownloadStore', () => {
|
||||
] 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 === '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', () => {
|
||||
useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://example.com/file.bin'],
|
||||
|
||||
+130
-61
@@ -7,11 +7,9 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
||||
import { categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
||||
import {
|
||||
expandTilde,
|
||||
resolveCategoryDestination,
|
||||
resolveDownloadFilePath
|
||||
resolveCategoryDestination
|
||||
} from '../utils/downloadLocations';
|
||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||
|
||||
@@ -42,6 +40,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
|
||||
const enqueueItem = {
|
||||
id: item.id,
|
||||
queue_id: item.queueId || MAIN_QUEUE_ID,
|
||||
url: item.url,
|
||||
destination,
|
||||
filename: item.fileName,
|
||||
@@ -61,8 +60,15 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
is_media: item.isMedia || false
|
||||
};
|
||||
|
||||
await invoke('enqueue_download', { item: enqueueItem });
|
||||
const order = await invoke('get_pending_order');
|
||||
const accepted = await invoke('enqueue_download', { item: enqueueItem });
|
||||
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().registerBackendIds([id]);
|
||||
return true;
|
||||
@@ -126,16 +132,26 @@ const syncSystemIntegrations = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveDownloadPath = async (destination: string, fileName: string) => {
|
||||
return resolveDownloadFilePath(await expandTilde(destination), fileName);
|
||||
};
|
||||
|
||||
const effectiveDestinationForItem = async (
|
||||
item: Pick<DownloadItem, 'destination' | 'category'>,
|
||||
settings: ReturnType<typeof useSettingsStore.getState>
|
||||
): Promise<string> =>
|
||||
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 const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
@@ -186,15 +202,15 @@ interface DownloadState {
|
||||
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
||||
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
|
||||
redownload: (id: string) => Promise<void>;
|
||||
resumeDownload: (id: string) => Promise<void>;
|
||||
startQueue: (queueId: string) => Promise<number>;
|
||||
resumeDownload: (id: string) => Promise<boolean>;
|
||||
startQueue: (queueId: string) => Promise<string[]>;
|
||||
pauseQueue: (queueId: string) => Promise<number>;
|
||||
startAll: () => Promise<number>;
|
||||
pauseAll: () => Promise<number>;
|
||||
assignToQueue: (ids: string[], queueId: string) => void;
|
||||
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
||||
addQueue: (name: string) => void;
|
||||
renameQueue: (id: string, name: string) => void;
|
||||
removeQueue: (id: string) => void;
|
||||
removeQueue: (id: string) => Promise<void>;
|
||||
initDB: () => Promise<void>;
|
||||
|
||||
}
|
||||
@@ -205,8 +221,27 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
pendingOrder: [],
|
||||
setPendingOrder: (order) => set({ pendingOrder: order }),
|
||||
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 {
|
||||
const order = await invoke('move_in_queue', { id, direction });
|
||||
const order = await invoke('move_in_queue', { id, queueId, direction });
|
||||
set({ pendingOrder: order });
|
||||
} catch (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) => {
|
||||
const settings = useSettingsStore.getState();
|
||||
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 = {
|
||||
...item,
|
||||
destination: destPath,
|
||||
status: action.type === 'add-to-queue' ? 'queued' : 'ready',
|
||||
queueId: action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID,
|
||||
status: action.type === 'add-to-queue' ? 'staged' : 'ready',
|
||||
queueId,
|
||||
queuePosition,
|
||||
hasBeenDispatched: false
|
||||
};
|
||||
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
|
||||
|
||||
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}`);
|
||||
} else if (action.type === 'start-now') {
|
||||
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.");
|
||||
}
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -373,18 +409,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
removeDownload: async (id, deleteFile = false) => {
|
||||
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) {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
await invoke('remove_download', { id, deleteAssets: deleteFile });
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
@@ -464,19 +490,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
},
|
||||
resumeDownload: async (id) => {
|
||||
const targetItem = get().downloads.find(d => d.id === id);
|
||||
if (!targetItem) return;
|
||||
if (!targetItem) return false;
|
||||
|
||||
try {
|
||||
if (targetItem.status === 'ready') {
|
||||
if (targetItem.status === 'ready' || targetItem.status === 'staged') {
|
||||
if (await dispatchItem(id)) {
|
||||
get().updateDownload(id, { hasBeenDispatched: true });
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const resumedExisting = await invoke('resume_download', { id });
|
||||
if (resumedExisting) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
get().unregisterBackendIds([id]);
|
||||
@@ -492,38 +519,50 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
|
||||
if (!await dispatchItem(id)) {
|
||||
console.error("Failed to re-enqueue for resume");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Failed to resume download:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
startQueue: async (queueId) => {
|
||||
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 promises = runnable.map(async (item) => {
|
||||
if (item.status === 'ready' || item.status === 'failed' || !item.hasBeenDispatched) {
|
||||
const acceptedIds: string[] = [];
|
||||
for (const item of runnable) {
|
||||
if (
|
||||
item.status === 'ready' ||
|
||||
item.status === 'staged' ||
|
||||
item.status === 'failed' ||
|
||||
!item.hasBeenDispatched ||
|
||||
!get().backendRegisteredIds.has(item.id)
|
||||
) {
|
||||
if (await dispatchItem(item.id)) {
|
||||
get().updateDownload(item.id, { hasBeenDispatched: true, status: 'queued' });
|
||||
dispatchedCount++;
|
||||
const current = get().downloads.find(download => download.id === item.id);
|
||||
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') {
|
||||
// If it's queued but already dispatched, it might be waiting.
|
||||
// If it's paused, we resume it.
|
||||
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, ${dispatchedCount} items dispatched/resumed`);
|
||||
return dispatchedCount;
|
||||
info(`Queue ${queueId} started, ${acceptedIds.length} items dispatched/resumed`);
|
||||
return acceptedIds;
|
||||
},
|
||||
pauseQueue: async (queueId) => {
|
||||
const activeIds = get().downloads
|
||||
@@ -556,7 +595,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
.map(item => item.queueId || MAIN_QUEUE_ID)
|
||||
);
|
||||
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 () => {
|
||||
const activeIds = get().downloads
|
||||
@@ -571,12 +610,39 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
syncSystemIntegrations();
|
||||
return pausedCount;
|
||||
},
|
||||
assignToQueue: (ids, queueId) => {
|
||||
assignToQueue: async (ids, queueId) => {
|
||||
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 => ({
|
||||
downloads: state.downloads.map(item =>
|
||||
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
|
||||
)
|
||||
}));
|
||||
@@ -599,9 +665,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
})
|
||||
}));
|
||||
},
|
||||
removeQueue: (id) => {
|
||||
removeQueue: async (id) => {
|
||||
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) => ({
|
||||
queues: state.queues.filter(q => q.id !== id),
|
||||
downloads: state.downloads.map(d =>
|
||||
@@ -619,10 +690,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
set(state => ({
|
||||
queues: queues.length > 0 ? queues : state.queues,
|
||||
downloads: downloads.length > 0
|
||||
? downloads.map(download => ({
|
||||
...download,
|
||||
queueId: download.queueId || MAIN_QUEUE_ID
|
||||
}))
|
||||
? normalizeQueuePositions(downloads)
|
||||
: state.downloads
|
||||
}));
|
||||
|
||||
@@ -655,6 +723,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
await resolveCategoryDestination(settings, item.category);
|
||||
itemsToEnqueue.push({
|
||||
id: item.id,
|
||||
queue_id: item.queueId || MAIN_QUEUE_ID,
|
||||
url: item.url,
|
||||
destination: destPath,
|
||||
filename: item.fileName,
|
||||
@@ -677,7 +746,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
const results = await invoke('enqueue_many', { items: itemsToEnqueue });
|
||||
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 order = await invoke('get_pending_order');
|
||||
const order = await invoke('get_pending_order', { queueId: null });
|
||||
set(state => ({
|
||||
pendingOrder: order,
|
||||
backendRegisteredIds: new Set([
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../utils/downloadLocations';
|
||||
|
||||
let settingsSave = Promise.resolve();
|
||||
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
const tauriStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
@@ -80,6 +81,7 @@ export interface SettingsState {
|
||||
activeSettingsTab: SettingsTab;
|
||||
scheduler: SchedulerSettings;
|
||||
schedulerRunning: boolean;
|
||||
schedulerActiveDownloadIds: string[];
|
||||
schedulerLastStartKey: string;
|
||||
schedulerLastStopKey: string;
|
||||
lastCustomSpeedLimitKiB: number;
|
||||
@@ -112,6 +114,7 @@ export interface SettingsState {
|
||||
setActiveSettingsTab: (tab: SettingsTab) => void;
|
||||
setScheduler: (settings: SchedulerSettings) => void;
|
||||
setSchedulerRunning: (running: boolean) => void;
|
||||
setSchedulerActiveDownloadIds: (ids: string[]) => void;
|
||||
setSchedulerLastStartKey: (key: string) => void;
|
||||
setSchedulerLastStopKey: (key: string) => void;
|
||||
setLastCustomSpeedLimitKiB: (limit: number) => void;
|
||||
@@ -187,9 +190,11 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
stopTime: '08:00',
|
||||
everyday: true,
|
||||
selectedDays: [0, 1, 2, 3, 4, 5, 6],
|
||||
selectedQueueIds: [DEFAULT_SCHEDULER_QUEUE_ID],
|
||||
postQueueAction: 'none'
|
||||
},
|
||||
schedulerRunning: false,
|
||||
schedulerActiveDownloadIds: [],
|
||||
schedulerLastStartKey: '',
|
||||
schedulerLastStopKey: '',
|
||||
lastCustomSpeedLimitKiB: 1024,
|
||||
@@ -231,6 +236,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
|
||||
setScheduler: (scheduler) => set({ scheduler }),
|
||||
setSchedulerRunning: (schedulerRunning) => set({ schedulerRunning }),
|
||||
setSchedulerActiveDownloadIds: (schedulerActiveDownloadIds) => set({ schedulerActiveDownloadIds }),
|
||||
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
|
||||
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
|
||||
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
|
||||
@@ -301,7 +307,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
{
|
||||
name: 'firelink-settings',
|
||||
storage: createJSONStorage(() => tauriStorage),
|
||||
version: 2,
|
||||
version: 3,
|
||||
migrate: (persistedState) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState as SettingsState;
|
||||
@@ -316,6 +322,15 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
return {
|
||||
...persisted,
|
||||
...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 : []
|
||||
} as SettingsState;
|
||||
},
|
||||
@@ -360,6 +375,14 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
...currentState,
|
||||
...persisted,
|
||||
...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,
|
||||
listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
|
||||
siteLogins: Array.isArray(persisted.siteLogins)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
|
||||
const STARTABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
'ready',
|
||||
'staged',
|
||||
'paused',
|
||||
'failed',
|
||||
]);
|
||||
@@ -29,7 +30,7 @@ export const canRedownload = (status: DownloadStatus): boolean =>
|
||||
REDOWNLOADABLE_STATUSES.has(status);
|
||||
|
||||
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 =>
|
||||
status === 'downloading' || status === 'processing' || status === 'retrying';
|
||||
|
||||
@@ -83,6 +83,15 @@ export const fileNameFromUrl = (rawUrl: string): string => {
|
||||
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 => {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
|
||||
Reference in New Issue
Block a user