fix(core): harden download lifecycle and scheduling

This commit is contained in:
NimBold
2026-06-22 11:19:18 +03:30
parent d535bdac8f
commit 06b14df307
38 changed files with 1257 additions and 428 deletions
+1 -1
View File
@@ -333,7 +333,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.4.0] - 2026-06-03
### 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.
+3 -3
View File
@@ -182,7 +182,7 @@ users. Manual QA is needed to confirm current focus behavior.
| SB-09 | Delete Queue | queue context menu | `removeQueue` | DB persistence subscription | Reassign items to Main Queue and delete custom queue | wired; no confirmation | unit test |
| SB-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 |
|---|---|---|---|---|---|---|
+173
View File
@@ -0,0 +1,173 @@
# YouTube and Media Download Handoff
## Repository state
The YouTube and media fixes were committed and pushed in:
- `a8b7920 fix(media): harden YouTube metadata loading`
- `340ef09 fix(media): correct YouTube size estimates`
- `226c791 fix(media): correct HLS progress tracking`
Do not discard unrelated worktree changes while working in this area.
## Non-negotiable architecture
### Keep the self-contained yt-dlp onedir distribution
Do not replace the bundled onedir distribution with yt-dlp onefile or a system
yt-dlp installation.
The onefile executable incurred roughly 17 seconds of extraction and startup
latency. Users cannot be expected to have Python or yt-dlp installed or
available through `PATH`.
The packaged layout must contain:
- `yt-dlp-<target>`
- The adjacent `_internal/` directory
- The embedded Python runtime
- The `yt_dlp_ejs` solver files
`scripts/verify-binaries.js` enforces this layout. Cross-platform builds must
provide an equivalent onedir distribution for each target. Do not implement
cross-platform support by falling back to a user-managed `PATH`.
### Keep metadata loading deterministic
The metadata implementation in `src-tauri/src/lib.rs` deliberately:
- Resolves only the bundled yt-dlp executable.
- Passes bundled Deno and FFmpeg by absolute path.
- Uses a minimal system `PATH`.
- Uses `--skip-download`.
- Requests only `title`, `duration`, `thumbnail`, and `formats`.
- Includes the cookie browser and credentials in the cache key.
- Deduplicates concurrent identical requests.
- Caches successful metadata for 60 seconds.
- Limits the cache to 128 entries.
Frontend request deduplication also exists in
`src/utils/mediaMetadata.ts`. Do not remove either deduplication layer. React
development behavior can otherwise start duplicate yt-dlp processes.
Deno is a JavaScript runtime used by yt-dlp extractors. It is not the metadata
engine.
### Keep media-format interpretation in the backend
`build_media_format_options` in `src-tauri/src/lib.rs` is the source of truth.
Do not reintroduce a separate format parser in `AddDownloadsModal.tsx`.
Duplicating this logic caused format selection and size estimation to drift.
Required invariants:
- Do not add a synthetic `Best` option.
- Exclude storyboards, MHTML, thumbnails, subtitles, and non-media entries.
- Match resolutions exactly. A 1080p stream must never produce a 1440p row.
- Bind each displayed option to concrete yt-dlp stream IDs, such as `301+251`.
- MKV, MP4, and WebM options must describe the streams actually selected.
- Do not add a separate audio stream when the selected video already has audio.
### Keep size values honest
Size semantics:
- `filesize` is exact.
- `filesize_approx` is approximate.
- When yt-dlp provides neither, estimate from bitrate multiplied by duration
and mark the result approximate.
- For split formats, combine the video and audio sizes.
- Approximate sizes in the UI must have a `~` prefix.
- Temporary component-stream totals must not overwrite the download row's
estimate.
- Successful completion must replace the estimate with the actual output file
size read from disk.
- Stores may treat a progress size as authoritative only when
`size_is_final` is true.
The primary UI consumers are:
- `src/components/AddDownloadsModal.tsx`
- `src/components/QualityModal.tsx`
### Do not trust temporary HLS byte totals for progress
yt-dlp can initially emit data similar to:
```text
downloaded_bytes=1024
total_bytes_estimate=1024
fragment_index=0
fragment_count=354
_percent_str=100.0%
```
This does not mean the download is complete. It represents an early HLS
fragment with a temporary size estimate.
`parse_media_progress_line` must therefore prefer:
```text
fragment_index / fragment_count
```
when fragment information is available.
For separate video and audio streams, `aggregate_media_fraction` advances to
the next track only when the previous track was effectively complete and the
new track restarts near zero. Do not restore the old heuristic that treated any
large percentage drop as a track transition.
Visible speed is derived from downloaded-byte deltas where possible. Raw
yt-dlp speed is only a fallback.
## Required regression checks
Before accepting changes to yt-dlp arguments, binary packaging, metadata,
progress, sizes, format selection, pause/resume, or the media UI, run:
```bash
cargo test --all-targets
npm run build
npm test -- --run
node scripts/verify-binaries.js
git diff --check
```
Run the live format smoke with a currently available multi-quality video:
```bash
FIRELINK_LIVE_YOUTUBE_URL='https://www.youtube.com/watch?v=<id>' \
cargo test filters_live_youtube_metadata_from_env --lib -- --ignored --nocapture
```
Preserve these Rust regression tests:
- `builds_compact_media_options_without_storyboards`
- `estimates_missing_video_size_from_bitrate_and_uses_exact_stream_ids`
- `uses_fragment_progress_instead_of_temporary_hls_size_estimates`
- `advances_tracks_only_after_a_completed_track_restarts`
- `derives_main_window_speed_from_downloaded_byte_delta`
- The structured yt-dlp, aria2, and legacy progress parser tests
## Manual packaged-app checks
Test the packaged application, not only development mode:
1. Launch it outside the repository working directory.
2. Select a browser cookie source.
3. Paste a YouTube URL into Add Downloads.
4. Confirm media formats appear within the expected warm-start budget, with a
target below eight seconds.
5. Confirm there is no `Best` row.
6. Confirm each displayed quality matches its actual resolution.
7. Confirm approximate sizes include `~`.
8. Start a split-stream or HLS download.
9. Confirm progress increases gradually instead of immediately reaching 100%.
10. Confirm the final displayed size equals the completed file's size on disk.
11. Pause and resume, confirming that the selected stream IDs and estimated
size remain intact.
Do not treat a deleted, private, age-restricted, or geographically unavailable
test video as proof of a product regression. Reproduce the problem with another
public multi-quality video first.
+1
View File
@@ -8,6 +8,7 @@
"core:window:allow-start-dragging",
"opener:default",
"dialog:default",
"log:default",
"notification:default",
"notification:allow-is-permission-granted"
]
+3 -44
View File
@@ -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> {
+5 -2
View File
@@ -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) => {
+47 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+5
View File
@@ -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");
}
+6 -1
View File
@@ -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() {
+49 -24
View File
@@ -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
View File
@@ -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 */}
+1 -1
View File
@@ -33,7 +33,7 @@ export class ErrorBoundary extends Component<Props, State> {
<div className="app-card max-w-lg space-y-4 p-6 text-center">
<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 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ActiveView = "downloads" | "settings" | "scheduler" | "speedLimiter" | "diagnostics";
export type ActiveView = "downloads" | "settings" | "scheduler" | "speedLimiter" | "logs";
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, hasBeenDispatched?: boolean, };
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadStatus = "ready" | "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying";
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying";
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type EnqueueAccepted = { id: string, filename: string, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type EnqueueItem = { id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, };
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type EnqueueResult = { id: string, success: boolean, error?: string, };
export type EnqueueResult = { id: string, success: boolean, filename?: string, error?: string, };
+1 -1
View File
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PostQueueAction } from "./PostQueueAction";
export type SchedulerSettings = { enabled: boolean, startTime: string, stopTimeEnabled: boolean, stopTime: string, everyday: boolean, selectedDays: Array<number>, postQueueAction: PostQueueAction, };
export type SchedulerSettings = { enabled: boolean, startTime: string, stopTimeEnabled: boolean, stopTime: string, everyday: boolean, selectedDays: Array<number>, selectedQueueIds: Array<string>, postQueueAction: PostQueueAction, };
+108 -44
View File
@@ -9,7 +9,7 @@ import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database,
import { open } from '@tauri-apps/plugin-dialog';
import { 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"
+24 -11
View File
@@ -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"
>
+8 -13
View File
@@ -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)}`}>
+73 -11
View File
@@ -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">
+1 -1
View File
@@ -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);
+14 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+26
View File
@@ -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 -5
View File
@@ -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
View File
@@ -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([
+24 -1
View File
@@ -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 -1
View File
@@ -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';
+9
View File
@@ -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);