diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a483aea..f24eefb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -262,7 +262,26 @@ jobs: version=$(grep -m1 '^version' src-tauri/Cargo.toml | sed 's/.*"\(.*\)"/\1/') fi bundle_dir="src-tauri/target/${{ matrix.target }}/release/bundle" - if [[ "$os" == "darwin" ]]; then + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + # Serve tauri's exact updater artifact so its minisign .sig stays valid. + # Renaming is fine — minisign signs file *contents*, not the filename. + sig=$(find "$bundle_dir" -name '*.sig' -type f | head -1) + if [[ -z "$sig" ]]; then + echo "::error::No updater .sig under $bundle_dir. Is TAURI_SIGNING_PRIVATE_KEY set?" + find "$bundle_dir" -maxdepth 3 -type f | sed 's/^/found: /' || true + exit 1 + fi + signed="${sig%.sig}" + case "$(basename "$signed")" in + *.app.tar.gz) ext="app.tar.gz" ;; + *.AppImage) ext="AppImage" ;; + *) ext="${signed##*.}" ;; + esac + asset="Risuko_${version}_${os}_${arch}.${ext}" + cp "$signed" "$asset" + cp "$sig" "$asset.sig" + echo "UPDATER_SIG_FILE=$asset.sig" >> "$GITHUB_ENV" + elif [[ "$os" == "darwin" ]]; then asset="Risuko_${version}_${os}_${arch}.app.tar.gz" tar czf "$asset" -C "$bundle_dir/macos" "Risuko.app" else @@ -285,9 +304,23 @@ jobs: $cargo = Get-Content "src-tauri/Cargo.toml" -Raw $version = [regex]::Match($cargo, '^version\s*=\s*"([^"]+)"', [System.Text.RegularExpressions.RegexOptions]::Multiline).Groups[1].Value } - $setup = Get-ChildItem "src-tauri/target/${{ matrix.target }}/release/bundle/nsis" -Filter "*-setup.exe" | Select-Object -First 1 + $nsis = "src-tauri/target/${{ matrix.target }}/release/bundle/nsis" $asset = "Risuko_${version}_${os}_${arch}.setup.exe" - Copy-Item $setup.FullName $asset + if ("${{ github.ref }}" -match "^refs/tags/") { + # Serve tauri's exact signed installer so its minisign .sig stays valid. + $sig = Get-ChildItem $nsis -Filter "*-setup.exe.sig" | Select-Object -First 1 + if (-not $sig) { + Write-Error "No updater .sig under $nsis (is TAURI_SIGNING_PRIVATE_KEY set?)" + exit 1 + } + $signed = $sig.FullName -replace '\.sig$', '' + Copy-Item $signed $asset + Copy-Item $sig.FullName "$asset.sig" + "UPDATER_SIG_FILE=$asset.sig" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 + } else { + $setup = Get-ChildItem $nsis -Filter "*-setup.exe" | Select-Object -First 1 + Copy-Item $setup.FullName $asset + } "APP_ASSET=$asset" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 "APP_PLATFORM_LABEL=${os}-${arch}" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 @@ -297,6 +330,34 @@ jobs: set -euo pipefail node scripts/write-sha256-sidecar.mjs "$APP_ASSET" + # Record this platform's updater entry (key + asset + signature) for the + # aggregation job that assembles latest.json across all matrix jobs. + - name: Emit updater manifest fragment + if: startsWith(github.ref, 'refs/tags/') + shell: bash + run: | + set -euo pipefail + triple="${{ matrix.target }}" + arch="${triple%%-*}" # x86_64 | aarch64 | i686 + case "$triple" in + *apple-darwin) tos=darwin ;; + *windows*) tos=windows ;; + *linux*) tos=linux ;; + *) echo "::error::unknown target triple $triple"; exit 1 ;; + esac + key="${tos}-${arch}" + signature=$(tr -d '\n' < "$UPDATER_SIG_FILE") + mkdir -p fragments + printf '{"key":"%s","asset":"%s","signature":"%s"}' "$key" "$APP_ASSET" "$signature" > "fragments/$key.json" + echo "fragment: fragments/${key}.json -> $APP_ASSET" + + - name: Upload updater fragment + if: startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@v4 + with: + name: updater-fragment-${{ matrix.target }} + path: fragments/*.json + - name: Upload standardized app bundle to GitHub Release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 @@ -305,6 +366,7 @@ jobs: files: | ${{ env.APP_ASSET }} ${{ env.APP_ASSET }}.sha256 + ${{ env.APP_ASSET }}.sig env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -440,3 +502,41 @@ jobs: else echo "DMG directory not found: $dmg_dir" fi + + updater-manifest: + name: Build updater latest.json + needs: release + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Download updater fragments + uses: actions/download-artifact@v4 + with: + path: fragments + pattern: updater-fragment-* + merge-multiple: true + + - name: Build latest.json + shell: bash + run: node scripts/build-updater-manifest.mjs fragments latest.json + env: + VERSION: ${{ github.ref_name }} + REPO: ${{ github.repository }} + + - name: Upload latest.json to GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + files: latest.json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README-CN.md b/README-CN.md index 961f2be1..f60377d2 100644 --- a/README-CN.md +++ b/README-CN.md @@ -1,7 +1,7 @@ # Risuko

- + Risuko App Icon

@@ -17,7 +17,7 @@ Risuko 是一款全能的下载工具,支持下载 HTTP、FTP、BT、磁力链等资源。它的界面简洁易用,希望大家喜欢 👻。 -✈️ 去 [官网](https://risuko.vercel.app) 逛逛 +✈️ 去 [官网](https://risuko.app) 逛逛 ## 💽 安装 diff --git a/README.md b/README.md index 9ab4b082..82aac682 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Risuko

- + Risuko App Icon

@@ -19,7 +19,7 @@ Risuko is a full-featured download manager that supports downloading HTTP, FTP, Risuko has a clean and easy to use interface. I hope you will like it 👻. -✈️ [Official Website](https://risuko.vercel.app) +✈️ [Official Website](https://risuko.app) ## 💽 Installation diff --git a/scripts/build-updater-manifest.mjs b/scripts/build-updater-manifest.mjs new file mode 100644 index 00000000..638a7420 --- /dev/null +++ b/scripts/build-updater-manifest.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node + + +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export function buildManifest(dir, { version, repo } = {}) { + version = String(version ?? "").replace(/^v/i, ""); + if (!version) throw new Error("version required"); + if (!repo) throw new Error("repo (owner/repo) required"); + const base = `https://github.com/${repo}/releases/download/v${version}`; + const platforms = {}; + for (const file of readdirSync(dir)) { + if (!file.endsWith(".json")) continue; + const frag = JSON.parse(readFileSync(join(dir, file), "utf8")); + if (!frag.key || !frag.asset || !frag.signature) { + throw new Error(`incomplete fragment: ${file}`); + } + platforms[frag.key] = { signature: frag.signature, url: `${base}/${frag.asset}` }; + } + if (Object.keys(platforms).length === 0) throw new Error(`no fragments in ${dir}`); + + return { version, notes: "", pub_date: new Date().toISOString(), platforms }; +} + +// CLI: node scripts/build-updater-manifest.mjs [out] +if (import.meta.url === `file://${process.argv[1]}`) { + const [dir, out = "latest.json"] = process.argv.slice(2); + if (!dir) { + console.error("Usage: build-updater-manifest.mjs [out]"); + process.exit(2); + } + const manifest = buildManifest(dir, { + version: process.env.VERSION, + repo: process.env.REPO, + }); + writeFileSync(out, `${JSON.stringify(manifest, null, 2)}\n`); + console.log(`wrote ${out}: ${Object.keys(manifest.platforms).join(", ")}`); +} diff --git a/scripts/build-updater-manifest.test.mjs b/scripts/build-updater-manifest.test.mjs new file mode 100644 index 00000000..6e59b47e --- /dev/null +++ b/scripts/build-updater-manifest.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildManifest } from "./build-updater-manifest.mjs"; + +const dir = mkdtempSync(join(tmpdir(), "frag-")); +writeFileSync( + join(dir, "darwin-aarch64.json"), + JSON.stringify({ + key: "darwin-aarch64", + asset: "Risuko_1.2.3_darwin_arm64.app.tar.gz", + signature: "SIG_A", + }), +); +writeFileSync( + join(dir, "windows-x86_64.json"), + JSON.stringify({ + key: "windows-x86_64", + asset: "Risuko_1.2.3_win32_x64.setup.exe", + signature: "SIG_W", + }), +); + +const m = buildManifest(dir, { + version: "v1.2.3", + repo: "YueMiyuki/Risuko", +}); + +assert.equal(m.version, "1.2.3"); +assert.match(m.pub_date, /^\d{4}-\d{2}-\d{2}T/); +assert.equal(m.platforms["darwin-aarch64"].signature, "SIG_A"); +assert.equal( + m.platforms["darwin-aarch64"].url, + "https://github.com/YueMiyuki/Risuko/releases/download/v1.2.3/Risuko_1.2.3_darwin_arm64.app.tar.gz", +); +assert.equal( + m.platforms["windows-x86_64"].url, + "https://github.com/YueMiyuki/Risuko/releases/download/v1.2.3/Risuko_1.2.3_win32_x64.setup.exe", +); +assert.throws(() => buildManifest(dir, { repo: "x/y" }), /version required/); + +rmSync(dir, { recursive: true, force: true }); +console.log("ok"); diff --git a/src-tauri/risuko-engine/src/engine/manager.rs b/src-tauri/risuko-engine/src/engine/manager.rs index 4dc99b10..4e240f6f 100644 --- a/src-tauri/risuko-engine/src/engine/manager.rs +++ b/src-tauri/risuko-engine/src/engine/manager.rs @@ -647,10 +647,16 @@ impl TaskManager { (decision.dir, decision.tag) } + fn send_download_start(&self, gid: &str) { + self.events.send(EngineEvent::DownloadStart { + gid: gid.to_string(), + }); + } + /// Common tail for the queue-based protocols: honor a per-task `pause` /// option (explicitly set, not from global defaults), enqueue the task, /// kick the scheduler, and emit DownloadStart - async fn enqueue(&self, task: DownloadTask) -> Result { + async fn enqueue(&self, mut task: DownloadTask) -> Result { let gid = task.gid.clone(); let pause = task .options @@ -658,14 +664,27 @@ impl TaskManager { .and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true"))) .unwrap_or(false); + // Scheduled start + let scheduled = task + .options + .get("risuko-start-at") + .and_then(|v| { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) + }) + .filter(|&ts| ts > 0); + if let Some(ts) = scheduled { + task.start_at = Some(ts); + task.status = TaskStatus::Scheduled; + } + self.tasks.write().await.push(task); - if !pause { + if scheduled.is_none() && !pause { self.try_start_next().await; } - self.events - .send(EngineEvent::DownloadStart { gid: gid.clone() }); + self.send_download_start(&gid); Ok(gid) } @@ -819,8 +838,7 @@ impl TaskManager { drop(te_guard); self.tasks.write().await.push(task); - self.events - .send(EngineEvent::DownloadStart { gid: gid.clone() }); + self.send_download_start(&gid); Ok(gid) } @@ -944,8 +962,7 @@ impl TaskManager { ) .await; } - self.events - .send(EngineEvent::DownloadStart { gid: gid.clone() }); + self.send_download_start(&gid); Ok(gid) } @@ -1406,32 +1423,38 @@ impl TaskManager { let mut merged = options_snapshot.merge_task_options(&task.options); self.apply_stored_cookies(&task.uris, &mut merged); self.spawn_http_download(task, merged); + self.send_download_start(&task.gid); started += 1; } else if task.kind == TaskKind::Media && !task.uris.is_empty() { task.status = TaskStatus::Active; let mut merged = options_snapshot.merge_task_options(&task.options); self.apply_stored_cookies(&task.uris, &mut merged); self.spawn_media_download(task, merged); + self.send_download_start(&task.gid); started += 1; } else if task.kind == TaskKind::M3u8 && !task.uris.is_empty() { task.status = TaskStatus::Active; let merged = options_snapshot.merge_task_options(&task.options); self.spawn_m3u8_download(task, merged); + self.send_download_start(&task.gid); started += 1; } else if task.kind == TaskKind::Ed2k && !task.uris.is_empty() { task.status = TaskStatus::Active; self.spawn_ed2k_download(task); + self.send_download_start(&task.gid); started += 1; } else if task.kind == TaskKind::Ftp && !task.uris.is_empty() { task.status = TaskStatus::Active; let merged = options_snapshot.merge_task_options(&task.options); self.spawn_ftp_download(task, merged); + self.send_download_start(&task.gid); started += 1; } else if task.kind == TaskKind::Metalink && !task.files.is_empty() { task.status = TaskStatus::Active; let merged = options_snapshot.merge_task_options(&task.options); apply_select_file(&mut task.files, &task.options); self.spawn_metalink_download(task, merged); + self.send_download_start(&task.gid); started += 1; } else if matches!( task.kind, @@ -1440,11 +1463,73 @@ impl TaskManager { { task.status = TaskStatus::Active; self.spawn_legacy_p2p_download(task); + self.send_download_start(&task.gid); started += 1; } } } + /// Enforce the strict-priority download queue + async fn reconcile_active_set(&self) { + let max_concurrent = self.options.read().await.max_concurrent_downloads(); + let to_preempt: Vec = { + let tasks = self.tasks.read().await; + let mut rank = 0usize; + let mut preempt = Vec::new(); + for task in tasks.iter() { + if task.kind == TaskKind::Torrent { + continue; + } + match task.status { + TaskStatus::Active | TaskStatus::Waiting => { + rank += 1; + if rank > max_concurrent && task.status == TaskStatus::Active { + preempt.push(task.gid.clone()); + } + } + _ => {} + } + } + preempt + }; + for gid in &to_preempt { + self.preempt(gid).await; + } + self.try_start_next().await; + } + + /// Pause an active download back to Waiting (not Paused) to yield its slot to + /// a higher-priority task. Unlike `pause`, the task stays runnable, so the next + /// reconcile restarts it once it re-enters the top-N. + /// + /// Set the status to Waiting BEFORE cancelling: the download's cancellation + /// handler only pauses a task that is still marked Active, so demoting first + /// keeps a preempted task runnable instead of racing it into a stuck Paused + async fn preempt(&self, gid: &str) { + let should_cancel = { + let mut tasks = self.tasks.write().await; + match tasks.iter_mut().find(|t| t.gid == gid) { + Some(task) if task.status == TaskStatus::Active => { + tracing::info!("[task:{}] Preempted, yielding download slot", gid); + task.status = TaskStatus::Waiting; + task.download_speed = 0; + task.upload_speed = 0; + self.events.send(EngineEvent::DownloadPause { + gid: gid.to_string(), + }); + true + } + _ => false, + } + }; + if should_cancel { + let active = self.active_downloads.read().await; + if let Some(ad) = active.get(gid) { + ad.cancel_token.cancel(); + } + } + } + /// Inject stored browser cookies into the merged options for an HTTP /// task whose URI host has a saved entry. User-supplied cookies and /// User-Agent on the task itself always win; the store only fills @@ -2045,14 +2130,14 @@ impl TaskManager { // Cap stopped task history once per tick to avoid long-uptime growth self.enforce_result_cap().await; - // If there are no Active or Waiting tasks, skip the - // expensive write-lock + per-task scan + try_start_next entirely - // This reduces per-second CPU wake-ups when the engine is idle { let tasks_ro = self.tasks.read().await; - let any_work = tasks_ro - .iter() - .any(|t| matches!(t.status, TaskStatus::Active | TaskStatus::Waiting)); + let any_work = tasks_ro.iter().any(|t| { + matches!( + t.status, + TaskStatus::Active | TaskStatus::Waiting | TaskStatus::Scheduled + ) + }); if !any_work { return; } @@ -2374,9 +2459,10 @@ impl TaskManager { } } } - // Start any waiting tasks if download slots are available + // Promote due scheduled tasks, then enforce the strict-priority queue self.ensure_active_magnet_resolvers().await; - self.try_start_next().await; + self.check_scheduled_tasks().await; + self.reconcile_active_set().await; } async fn ensure_active_magnet_resolvers(&self) { @@ -2490,9 +2576,146 @@ impl TaskManager { self.try_start_next().await; } - self.events.send(EngineEvent::DownloadStart { - gid: gid.to_string(), - }); + self.send_download_start(gid); + Ok(()) + } + + /// Promote scheduled tasks whose start time has arrived + async fn check_scheduled_tasks(&self) { + // ponytail: 5-min tolerance separates "came due while running" from + // "overdue because we were off"; tune if wake-from-sleep feels wrong + const GRACE_SECS: u64 = 300; + let now = crate::engine::util::now_secs(); + let mut tasks = self.tasks.write().await; + for task in tasks.iter_mut() { + if task.status != TaskStatus::Scheduled || task.schedule_missed { + continue; + } + let Some(ts) = task.start_at else { continue }; + if now < ts { + continue; + } + if now - ts <= GRACE_SECS { + tracing::info!( + "[task:{}] Scheduled task now due, promoting to Waiting (now={}, start_at={}, elapsed={}s)", + task.gid, + now, + ts, + now - ts + ); + task.status = TaskStatus::Waiting; + task.start_at = None; + } else { + tracing::warn!( + "[task:{}] Scheduled task missed (now={}, start_at={}, overdue by {}s)", + task.gid, + now, + ts, + now - ts + ); + task.schedule_missed = true; + } + } + } + + pub async fn set_task_schedule(&self, gid: &str, start_at: u64) -> Result<(), String> { + let now = crate::engine::util::now_secs(); + tracing::info!( + "[task:{}] set_task_schedule: start_at={}, now={}, delay={}s", + gid, + start_at, + now, + start_at.saturating_sub(now) + ); + { + let active = self.active_downloads.read().await; + if let Some(ad) = active.get(gid) { + ad.cancel_token.cancel(); + } + } + { + let mut tasks = self.tasks.write().await; + let task = tasks + .iter_mut() + .find(|t| t.gid == gid) + .ok_or_else(|| format!("Task {} not found", gid))?; + if task.kind == TaskKind::Torrent { + return Err("Scheduling torrent tasks is not supported".to_string()); + } + if task.status.is_stopped() { + return Err(format!("Task {} is already finished", gid)); + } + task.status = TaskStatus::Scheduled; + task.start_at = Some(start_at); + task.schedule_missed = false; + task.download_speed = 0; + task.upload_speed = 0; + } + // The freed slot + self.reconcile_active_set().await; + Ok(()) + } + + /// Start a scheduled task immediately + clearing its schedule + pub async fn start_task_now(&self, gid: &str) -> Result<(), String> { + tracing::info!( + "[task:{}] start_task_now: manually starting scheduled task", + gid + ); + { + let mut tasks = self.tasks.write().await; + let task = tasks + .iter_mut() + .find(|t| t.gid == gid) + .ok_or_else(|| format!("Task {} not found", gid))?; + if task.status != TaskStatus::Scheduled { + return Err(format!("Task {} is not scheduled", gid)); + } + task.status = TaskStatus::Waiting; + task.start_at = None; + task.schedule_missed = false; + } + self.reconcile_active_set().await; + self.send_download_start(gid); + Ok(()) + } + + /// Move one or more tasks + pub async fn move_tasks( + &self, + gids: &[String], + target_gid: &str, + after: bool, + ) -> Result<(), String> { + { + let mut guard = self.tasks.write().await; + let move_set: std::collections::HashSet<&str> = + gids.iter().map(|s| s.as_str()).collect(); + let mut moved = Vec::new(); + let mut remaining = Vec::new(); + for t in std::mem::take(&mut *guard) { + if move_set.contains(t.gid.as_str()) { + moved.push(t); + } else { + remaining.push(t); + } + } + if moved.is_empty() { + *guard = remaining; + return Err("no matching tasks to move".to_string()); + } + let insert_at = remaining + .iter() + .position(|t| t.gid == target_gid) + .map(|p| if after { p + 1 } else { p }) + .unwrap_or(remaining.len()); + let mut result = Vec::with_capacity(remaining.len() + moved.len()); + result.extend(remaining.drain(..insert_at)); + result.extend(moved); + result.extend(remaining); + *guard = result; + } + self.reconcile_active_set().await; Ok(()) } @@ -2543,9 +2766,7 @@ impl TaskManager { } } self.try_start_next().await; - self.events.send(EngineEvent::DownloadStart { - gid: gid.to_string(), - }); + self.send_download_start(gid); Ok(()) } @@ -2620,6 +2841,16 @@ impl TaskManager { Value::Array(Self::paginate_newest_first(&stopped, offset, num, keys)) } + pub async fn tell_scheduled(&self, offset: i64, num: usize, keys: &[String]) -> Value { + let tasks = self.tasks.read().await; + let scheduled: Vec<&DownloadTask> = tasks + .iter() + .filter(|t| t.status == TaskStatus::Scheduled) + .collect(); + let num = num.min(10_000); + Value::Array(Self::paginate_newest_first(&scheduled, offset, num, keys)) + } + fn paginate_newest_first( items: &[&DownloadTask], offset: i64, @@ -2658,7 +2889,9 @@ impl TaskManager { dl_speed += task.download_speed; ul_speed += task.upload_speed; } - TaskStatus::Waiting | TaskStatus::Paused => num_waiting += 1, + TaskStatus::Waiting | TaskStatus::Paused | TaskStatus::Scheduled => { + num_waiting += 1 + } _ => num_stopped += 1, } } @@ -3205,9 +3438,7 @@ impl TaskManager { } else { task.status = TaskStatus::Waiting; } - self.events.send(EngineEvent::DownloadStart { - gid: task.gid.clone(), - }); + self.send_download_start(&task.gid); } } } @@ -3629,6 +3860,70 @@ mod tests { task } + #[tokio::test] + async fn move_tasks_moves_the_block_next_to_the_target() { + // Paused tasks so the reconcile at the end never spawns a real download + let mgr = make_test_manager(vec![ + make_task("a", TaskStatus::Paused), + make_task("b", TaskStatus::Paused), + make_task("c", TaskStatus::Paused), + make_task("d", TaskStatus::Paused), + ]); + + // Move [c, d] to just before "a" + mgr.move_tasks(&["c".into(), "d".into()], "a", false) + .await + .unwrap(); + + let order: Vec = mgr + .tasks + .read() + .await + .iter() + .map(|t| t.gid.clone()) + .collect(); + assert_eq!(order, vec!["c", "d", "a", "b"]); + } + + #[tokio::test] + async fn check_scheduled_promotes_due_and_flags_missed() { + let now = crate::engine::util::now_secs(); + let mut due = make_task("due", TaskStatus::Scheduled); + due.start_at = Some(now.saturating_sub(5)); // just past -> within grace, start it + let mut missed = make_task("missed", TaskStatus::Scheduled); + missed.start_at = Some(now.saturating_sub(10_000)); // long overdue -> flag missed + let mut future = make_task("future", TaskStatus::Scheduled); + future.start_at = Some(now + 10_000); // not yet due + + let mgr = make_test_manager(vec![due, missed, future]); + mgr.check_scheduled_tasks().await; + + let tasks = mgr.tasks.read().await; + let get = |gid: &str| tasks.iter().find(|t| t.gid == gid).unwrap(); + assert_eq!(get("due").status, TaskStatus::Waiting); + assert!(get("due").start_at.is_none()); + assert_eq!(get("missed").status, TaskStatus::Scheduled); + assert!(get("missed").schedule_missed); + assert_eq!(get("future").status, TaskStatus::Scheduled); + assert!(!get("future").schedule_missed); + } + + #[tokio::test] + async fn update_progress_runs_scheduler_when_only_scheduled_tasks_exist() { + let now = crate::engine::util::now_secs(); + let mut due = make_task("due", TaskStatus::Scheduled); + due.kind = TaskKind::Torrent; + due.start_at = Some(now.saturating_sub(5)); + let mgr = make_test_manager(vec![due]); + + mgr.update_progress().await; + + let tasks = mgr.tasks.read().await; + let task = tasks.iter().find(|t| t.gid == "due").unwrap(); + assert_eq!(task.status, TaskStatus::Waiting); + assert!(task.start_at.is_none()); + } + async fn make_test_manager_with_engine() -> (TaskManager, tempfile::TempDir) { let dir = tempfile::TempDir::new().unwrap(); let mut system = Map::new(); diff --git a/src-tauri/risuko-engine/src/engine/task.rs b/src-tauri/risuko-engine/src/engine/task.rs index 6ffaa2a0..fc2447b7 100644 --- a/src-tauri/risuko-engine/src/engine/task.rs +++ b/src-tauri/risuko-engine/src/engine/task.rs @@ -8,6 +8,7 @@ pub enum TaskStatus { #[default] Waiting, Paused, + Scheduled, Complete, Error, Removed, @@ -19,6 +20,7 @@ impl TaskStatus { Self::Active => "active", Self::Waiting => "waiting", Self::Paused => "paused", + Self::Scheduled => "scheduled", Self::Complete => "complete", Self::Error => "error", Self::Removed => "removed", @@ -114,37 +116,31 @@ pub struct DownloadTask { pub tag: Option, // BitTorrent pub info_hash: Option, - /// BEP-52 v2 (SHA-256) info-hash. Set for hybrid and pure-v2 torrents #[serde(default)] pub info_hash_v2: Option, - /// BEP-52 metadata version: "v1", "v2", or "hybrid" #[serde(default)] pub meta_version: Option, pub bt_name: Option, pub seeder: bool, pub num_seeders: u32, pub peers: Vec, - /// Piece length in bytes (BT only). Populated once metadata is received #[serde(default)] pub piece_length: u32, - /// Total number of pieces (BT only) #[serde(default)] pub num_pieces: u32, - /// Free-form torrent comment from the .torrent file #[serde(default)] pub bt_comment: Option, - /// Unix epoch seconds for torrent creation, when present #[serde(default)] pub bt_creation_date: Option, - /// BEP-12 announce-list, tier first then trackers within tier #[serde(default)] pub bt_announce_list: Vec>, - // Internal tracking pub created_at: u64, - /// Timestamp (ms) when seeding started, 0 if not seeding #[serde(default)] pub seeding_since: u64, - /// split progress for multi-thread HTTP downloads (transient) + #[serde(default)] + pub start_at: Option, + #[serde(default)] + pub schedule_missed: bool, #[serde(skip, default)] pub chunk_progress: Vec, } @@ -577,6 +573,13 @@ impl DownloadTask { Value::String(self.created_at.to_string()), ); + if let Some(ts) = self.start_at { + m.insert("startAt".into(), Value::String(ts.to_string())); + } + if self.schedule_missed { + m.insert("scheduleMissed".into(), Value::Bool(true)); + } + // BitTorrent fields if self.kind == TaskKind::Torrent { let mut bt = Map::new(); diff --git a/src-tauri/src/commands/engine_cmds.rs b/src-tauri/src/commands/engine_cmds.rs index ff11955b..50926b43 100644 --- a/src-tauri/src/commands/engine_cmds.rs +++ b/src-tauri/src/commands/engine_cmds.rs @@ -1,11 +1,9 @@ use std::path::Path; -use std::time::Duration; use std::{collections::HashMap, collections::HashSet}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use tauri::{AppHandle, Manager}; -use tokio::time::sleep; use risuko_engine::engine; use risuko_engine::engine::torrent; @@ -36,20 +34,6 @@ pub struct AutoRetryPlanResult { pub delay_ms: u64, } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SelectedTaskOrderInput { - pub gid: String, - pub status: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SyncOrderResult { - pub moved: u32, - pub partial_error: bool, -} - fn normalize_non_negative(value: f64) -> u64 { if !value.is_finite() || value <= 0.0 { return 0; @@ -874,92 +858,51 @@ pub async fn resolve_magnet( })) } +/// Move a drag selection to sit before/after a neighbor task in the queue #[tauri::command] -pub async fn sync_selected_task_order( - _state: tauri::State<'_, crate::state::AppState>, - direction: String, - selected_tasks: Vec, -) -> Result { - let normalized_direction = direction.trim().to_ascii_lowercase(); - if normalized_direction != "up" && normalized_direction != "down" { - return Err("Invalid direction".to_string()); - } - - let mut seen_gids = HashSet::new(); - let mut selected_active_gids = Vec::new(); - for task in selected_tasks { - let gid = task.gid.trim().to_string(); - if gid.is_empty() || !seen_gids.insert(gid.clone()) { - continue; - } - - if task.status.eq_ignore_ascii_case("active") { - selected_active_gids.push(gid); - } - } - - if seen_gids.is_empty() { - return Ok(SyncOrderResult { - moved: 0, - partial_error: false, - }); - } - +pub async fn reorder_tasks( + gids: Vec, + target_gid: String, + after: bool, +) -> Result<(), String> { let manager = engine::get_manager().await.ok_or("Engine not running")?; + manager.move_tasks(&gids, &target_gid, after).await?; + let _ = manager.save_session().await; + Ok(()) +} - let mut sync_error = false; - let mut moved: u32 = 0; - - // Pause active tasks first - if !selected_active_gids.is_empty() { - for gid in &selected_active_gids { - if manager.pause(gid).await.is_err() { - sync_error = true; - } - } - // little delay for status transition - sleep(Duration::from_millis(100)).await; - } - - // Move tasks one position at a time, matching frontend behavior - - // For "up": iterate in forward order so earlier items move first - // preserving relative order among selected tasks - // For "down": iterate in reverse order so later items move first - let ordered_gids: Vec = if normalized_direction == "up" { - // Get current waiting queue order, filter to selected - manager.get_waiting_gids_in_order(&seen_gids).await - } else { - let mut v = manager.get_waiting_gids_in_order(&seen_gids).await; - v.reverse(); - v - }; - - for gid in &ordered_gids { - let pos = if normalized_direction == "up" { - -1i64 - } else { - 1i64 - }; - match manager.change_position(gid, pos, "POS_CUR").await { - Ok(_) => moved += 1, - Err(_) => sync_error = true, - } - } +/// Hold a task for a scheduled start +#[tauri::command] +pub async fn set_task_schedule(gid: String, start_at: u64) -> Result<(), String> { + let manager = engine::get_manager().await.ok_or("Engine not running")?; + manager.set_task_schedule(&gid, start_at).await?; + let _ = manager.save_session().await; + Ok(()) +} - // Unpause previously active tasks - if !selected_active_gids.is_empty() { - for gid in &selected_active_gids { - if manager.unpause(gid).await.is_err() { - sync_error = true; - } - } - } +/// Start a scheduled task immediately + clearing its schedule +#[tauri::command] +pub async fn start_task_now(gid: String) -> Result<(), String> { + let manager = engine::get_manager().await.ok_or("Engine not running")?; + manager.start_task_now(&gid).await?; + let _ = manager.save_session().await; + Ok(()) +} - Ok(SyncOrderResult { - moved, - partial_error: sync_error, - }) +#[tauri::command] +pub async fn tell_scheduled( + offset: Option, + num: Option, + keys: Option>, +) -> Result { + let manager = engine::get_manager().await.ok_or("Engine not running")?; + Ok(manager + .tell_scheduled( + offset.unwrap_or(0), + num.unwrap_or(5000), + &keys.unwrap_or_default(), + ) + .await) } // Tauri commands wrapping TaskManager for direct invoke() calls diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e4f2265..51021e5b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -445,11 +445,14 @@ pub fn run() { commands::engine_cmds::resolve_magnet, commands::engine_cmds::evaluate_low_speed_tasks, commands::engine_cmds::plan_auto_retry, - commands::engine_cmds::sync_selected_task_order, + commands::engine_cmds::reorder_tasks, + commands::engine_cmds::set_task_schedule, + commands::engine_cmds::start_task_now, commands::engine_cmds::tell_status, commands::engine_cmds::tell_active, commands::engine_cmds::tell_waiting, commands::engine_cmds::tell_stopped, + commands::engine_cmds::tell_scheduled, commands::engine_cmds::pause_task, commands::engine_cmds::unpause_task, commands::engine_cmds::remove_task, diff --git a/src-tauri/src/managers/clip_prompt.rs b/src-tauri/src/managers/clip_prompt.rs index d990fdc3..874da37d 100644 --- a/src-tauri/src/managers/clip_prompt.rs +++ b/src-tauri/src/managers/clip_prompt.rs @@ -102,7 +102,7 @@ mod prev_focus { let self_pid = std::process::id() as i32; let pid = NSWorkspace::sharedWorkspace() .frontmostApplication() - .map(|a| a.processIdentifier() as i32) + .map(|a| a.processIdentifier()) .filter(|&p| p != self_pid) .unwrap_or(0); PREV_APP_PID.store(pid, Ordering::SeqCst); diff --git a/src-tauri/src/managers/menu.rs b/src-tauri/src/managers/menu.rs index 77407c3c..c4e527ec 100644 --- a/src-tauri/src/managers/menu.rs +++ b/src-tauri/src/managers/menu.rs @@ -236,8 +236,6 @@ fn build_task_submenu( .item(&mi("pause-task", "Pause Task").build(handle)?) .item(&mi("resume-task", "Resume Task").build(handle)?) .item(&mi("delete-task", "Delete Task").build(handle)?) - .item(&mi("move-task-up", "Move Task Up").build(handle)?) - .item(&mi("move-task-down", "Move Task Down").build(handle)?) .separator() .item( &mi("pause-all-task", "Pause All Tasks") @@ -319,8 +317,6 @@ fn setup_menu_event_handler(app: &App) { "pause-task" => emit_command(app, "application:pause-task"), "resume-task" => emit_command(app, "application:resume-task"), "delete-task" => emit_command(app, "application:delete-task"), - "move-task-up" => emit_command(app, "application:move-task-up"), - "move-task-down" => emit_command(app, "application:move-task-down"), "pause-all-task" => emit_command(app, "application:pause-all-task"), "resume-all-task" => emit_command(app, "application:resume-all-task"), "select-all-task" => emit_command(app, "application:select-all-task"), @@ -335,7 +331,7 @@ fn setup_menu_event_handler(app: &App) { } } "official-website" => { - let _ = open::that("https://risuko.vercel.app"); + let _ = open::that("https://risuko.app"); } "manual" => { let _ = open::that("https://github.com/YueMiyuki/Risuko/wiki"); diff --git a/src/renderer/api/Api.ts b/src/renderer/api/Api.ts index 81939402..ed19adb6 100644 --- a/src/renderer/api/Api.ts +++ b/src/renderer/api/Api.ts @@ -9,7 +9,6 @@ import type { LowSpeedEvaluationResult, MediaInfo, PeerInfo, - SyncOrderResult, } from "@shared/types/task"; import type { UploadJob, @@ -242,14 +241,16 @@ export default class Api { }); } - syncSelectedTaskOrder( - params: { direction?: string; selectedTasks?: DownloadTask[] } = {}, - ) { - const { direction = "up", selectedTasks = [] } = params; - return invoke("sync_selected_task_order", { - direction, - selectedTasks, - }); + reorderTasks(params: { gids: string[]; targetGid: string; after: boolean }) { + return invoke("reorder_tasks", params); + } + + setTaskSchedule(params: { gid: string; startAt: number }) { + return invoke("set_task_schedule", params); + } + + startTaskNow(params: { gid: string }) { + return invoke("start_task_now", params); } addUri(params: { @@ -352,6 +353,17 @@ export default class Api { }); } + fetchScheduledTaskList( + params: { offset?: number; num?: number; keys?: string[] } = {}, + ) { + const { offset = 0, num = TASK_LIST_FETCH_SIZE, keys } = params; + return invoke("tell_scheduled", { + offset, + num, + keys, + }); + } + async fetchActiveTaskList( params: { offset?: number; num?: number; keys?: string[] } = {}, ) { @@ -376,6 +388,8 @@ export default class Api { return this.fetchWaitingTaskList(params); case "stopped": return this.fetchStoppedTaskList(params); + case "scheduled": + return this.fetchScheduledTaskList(params); default: return this.fetchActiveTaskList(params); } @@ -385,15 +399,17 @@ export default class Api { params: { offset?: number; num?: number; keys?: string[] } = {}, ) { const { offset = 0, num = TASK_LIST_FETCH_SIZE, keys } = params; - const [active, waiting, stopped] = await Promise.all([ + const [active, waiting, scheduled, stopped] = await Promise.all([ invoke("tell_active", { keys }), invoke("tell_waiting", { offset, num, keys }), + invoke("tell_scheduled", { offset, num, keys }), invoke("tell_stopped", { offset, num, keys }), ]); const activeArr = Array.isArray(active) ? active : []; const waitingArr = Array.isArray(waiting) ? waiting : []; + const scheduledArr = Array.isArray(scheduled) ? scheduled : []; const stoppedArr = Array.isArray(stopped) ? stopped : []; - return [...activeArr, ...waitingArr, ...stoppedArr]; + return [...activeArr, ...waitingArr, ...scheduledArr, ...stoppedArr]; } fetchTaskItem(params: { gid: string }) { diff --git a/src/renderer/components/About/Copyright.vue b/src/renderer/components/About/Copyright.vue index 4d5293ed..6fe24f2b 100644 --- a/src/renderer/components/About/Copyright.vue +++ b/src/renderer/components/About/Copyright.vue @@ -3,7 +3,7 @@ ©{{ year }} Risuko diff --git a/src/renderer/components/Main.vue b/src/renderer/components/Main.vue index 0d171277..041d7593 100644 --- a/src/renderer/components/Main.vue +++ b/src/renderer/components/Main.vue @@ -35,6 +35,7 @@ + @@ -50,6 +51,7 @@ import Dragger from "@/components/Dragger/Index.vue"; import TitleBar from "@/components/Native/TitleBar.vue"; import Sidebar from "@/components/Sidebar/Index.vue"; import AddTask from "@/components/Task/AddTask.vue"; +import MissedScheduleDialog from "@/components/Task/MissedScheduleDialog.vue"; import is from "@/shims/platform"; import { useAppStore } from "@/store/app"; import { useTaskStore } from "@/store/task"; @@ -61,6 +63,7 @@ export default { [Sidebar.name]: Sidebar, [TitleBar.name]: TitleBar, [AddTask.name]: AddTask, + [MissedScheduleDialog.name]: MissedScheduleDialog, [Dragger.name]: Dragger, ListTodo, Plus, diff --git a/src/renderer/components/Native/EngineClient.vue b/src/renderer/components/Native/EngineClient.vue index 51ad3bd8..84959c19 100644 --- a/src/renderer/components/Native/EngineClient.vue +++ b/src/renderer/components/Native/EngineClient.vue @@ -521,6 +521,9 @@ export default { taskStore.saveSession(); const { gid } = payload; this.clearAutoRetryState(gid); + if (taskStore.currentTaskItem?.gid === gid) { + taskStore.updateCurrentTaskDetail(); + } const { seedingList } = this; if (seedingList.includes(gid)) { return; @@ -635,7 +638,7 @@ export default { } else { message = this.$t("task.download-error-message", { taskName }); } - const link = `https://risuko.vercel.app/docs/reference/error-codes#${errorCode}`; + const link = `https://risuko.app/docs/reference/error-codes#${errorCode}`; this.$msg.error({ duration: isMissingYtDlp ? 9000 : 5000, message: `${message} (${errorCode}) ${link}`, @@ -1027,6 +1030,7 @@ export default { return; } this.startupAutoResumeHandled = true; + this.checkMissedSchedules(); if (!this.resumeAllWhenAppLaunched) { return; @@ -1041,6 +1045,19 @@ export default { ); }); }, + checkMissedSchedules() { + api + .fetchScheduledTaskList() + .then((tasks) => { + const missed = (Array.isArray(tasks) ? tasks : []).filter( + (t) => t.scheduleMissed, + ); + if (missed.length > 0) { + useAppStore().showMissedScheduled(missed); + } + }) + .catch(() => {}); + }, }, created() { this.bindEngineEvents().catch((err) => { diff --git a/src/renderer/components/Sidebar/Index.vue b/src/renderer/components/Sidebar/Index.vue index 8a4196c6..5fb684bd 100644 --- a/src/renderer/components/Sidebar/Index.vue +++ b/src/renderer/components/Sidebar/Index.vue @@ -124,6 +124,7 @@ import { ArrowDown, ArrowUp, CircleCheck, + Clock, Info, LayoutList, Pause, @@ -219,6 +220,13 @@ export default { route: "/task/stopped", count: "stopped", }, + { + key: "task:scheduled", + icon: Clock, + label: "task.scheduled", + route: "/task/scheduled", + count: "scheduled", + }, ]; }, appItems() { diff --git a/src/renderer/components/Task/AddTask.vue b/src/renderer/components/Task/AddTask.vue index aa7cbd8c..abf0107c 100644 --- a/src/renderer/components/Task/AddTask.vue +++ b/src/renderer/components/Task/AddTask.vue @@ -204,6 +204,15 @@ {{ $t('task.navigate-to-downloading') }} +
+ + +
+
@@ -107,6 +111,7 @@ import logger from "@shared/utils/logger"; import api from "@/api"; import { commands } from "@/components/CommandManager/instance"; import SubnavSwitcher from "@/components/Subnav/SubnavSwitcher.vue"; +import ScheduleDialog from "@/components/Task/ScheduleDialog.vue"; import TaskActions from "@/components/Task/TaskActions.vue"; import TaskList from "@/components/Task/TaskList.vue"; import TaskDetail from "@/components/TaskDetail/Index.vue"; @@ -132,6 +137,7 @@ export default { [TaskActions.name]: TaskActions, [TaskList.name]: TaskList, [TaskDetail.name]: TaskDetail, + [ScheduleDialog.name]: ScheduleDialog, [SubnavSwitcher.name]: SubnavSwitcher, [LoadingOverlay.name]: LoadingOverlay, Select, @@ -202,6 +208,11 @@ export default { title: this.$t("task.stopped"), route: "/task/stopped", }, + { + key: "scheduled", + title: this.$t("task.scheduled"), + route: "/task/scheduled", + }, ]; }, title() { @@ -273,6 +284,8 @@ export default { return { taskActionLoading: false, taskActionLoadingText: "", + scheduleDialogVisible: false, + scheduleTask: null, }; }, methods: { @@ -656,6 +669,21 @@ export default { const { task } = payload; useTaskStore().showTaskDetail(task); }, + handleScheduleTask(payload) { + this.scheduleTask = payload.task; + this.scheduleDialogVisible = true; + }, + handleStartTaskNow(payload) { + const { task, taskName } = payload; + useTaskStore() + .startNow(task.gid) + .catch((err: unknown) => { + this.$msg.error( + (err as Error)?.message || + this.$t("task.resume-task-fail", { taskName }), + ); + }); + }, }, created() { this.changeCurrentList(); @@ -671,6 +699,8 @@ export default { commands.on("batch-delete-task", this.handleBatchDeleteTask); commands.on("copy-task-link", this.handleCopyTaskLink); commands.on("show-task-info", this.handleShowTaskInfo); + commands.on("schedule-task", this.handleScheduleTask); + commands.on("start-task-now", this.handleStartTaskNow); }, beforeUnmount() { commands.off("pause-task", this.handlePauseTask); @@ -683,6 +713,8 @@ export default { commands.off("batch-delete-task", this.handleBatchDeleteTask); commands.off("copy-task-link", this.handleCopyTaskLink); commands.off("show-task-info", this.handleShowTaskInfo); + commands.off("schedule-task", this.handleScheduleTask); + commands.off("start-task-now", this.handleStartTaskNow); }, }; diff --git a/src/renderer/components/Task/MissedScheduleDialog.vue b/src/renderer/components/Task/MissedScheduleDialog.vue new file mode 100644 index 00000000..a7307768 --- /dev/null +++ b/src/renderer/components/Task/MissedScheduleDialog.vue @@ -0,0 +1,125 @@ + + + diff --git a/src/renderer/components/Task/ScheduleDialog.vue b/src/renderer/components/Task/ScheduleDialog.vue new file mode 100644 index 00000000..2408f6ee --- /dev/null +++ b/src/renderer/components/Task/ScheduleDialog.vue @@ -0,0 +1,113 @@ + + + diff --git a/src/renderer/components/Task/TaskActions.vue b/src/renderer/components/Task/TaskActions.vue index 95c0a5d5..5d855270 100644 --- a/src/renderer/components/Task/TaskActions.vue +++ b/src/renderer/components/Task/TaskActions.vue @@ -43,36 +43,6 @@ - - - - - - - - - - import { - ArrowDown, - ArrowUp, Eraser, ListChecks, Pause, @@ -119,7 +87,6 @@ import { Trash2, } from "@lucide/vue"; import { bytesToSize } from "@shared/utils"; -import { toast } from "vue-sonner"; import { commands } from "@/components/CommandManager/instance"; import { useTaskStore } from "@/store/task"; @@ -132,8 +99,6 @@ export default { Pause, Eraser, ListChecks, - ArrowUp, - ArrowDown, }, data() { return { @@ -261,42 +226,6 @@ export default { }); } }, - onMoveUpClick() { - useTaskStore() - .moveSelectedTasks("up", { - onSyncError: () => { - toast.error(this.$t("task.sync-priority-failed"), { - duration: 1800, - }); - }, - }) - .then((movedCount) => { - if (movedCount > 0) { - this.$msg.success(this.$t("task.move-task-up")); - } - }) - .catch(() => { - this.$msg.error(this.$t("task.move-task-up")); - }); - }, - onMoveDownClick() { - useTaskStore() - .moveSelectedTasks("down", { - onSyncError: () => { - toast.error(this.$t("task.sync-priority-failed"), { - duration: 1800, - }); - }, - }) - .then((movedCount) => { - if (movedCount > 0) { - this.$msg.success(this.$t("task.move-task-down")); - } - }) - .catch(() => { - this.$msg.error(this.$t("task.move-task-down")); - }); - }, onPurgeRecordClick() { useTaskStore() .purgeTaskRecord() diff --git a/src/renderer/components/Task/TaskItem.vue b/src/renderer/components/Task/TaskItem.vue index ab178470..b8015c88 100644 --- a/src/renderer/components/Task/TaskItem.vue +++ b/src/renderer/components/Task/TaskItem.vue @@ -2,11 +2,22 @@
+ + +
{{ taskFullName }} @@ -68,7 +79,7 @@ diff --git a/src/renderer/components/ui/date-time-picker/DateTimePicker.vue b/src/renderer/components/ui/date-time-picker/DateTimePicker.vue new file mode 100644 index 00000000..1c78c9cc --- /dev/null +++ b/src/renderer/components/ui/date-time-picker/DateTimePicker.vue @@ -0,0 +1,256 @@ + + + diff --git a/src/renderer/components/ui/date-time-picker/index.ts b/src/renderer/components/ui/date-time-picker/index.ts new file mode 100644 index 00000000..6b2c3293 --- /dev/null +++ b/src/renderer/components/ui/date-time-picker/index.ts @@ -0,0 +1 @@ +export { default as DateTimePicker } from "./DateTimePicker.vue"; diff --git a/src/renderer/pages/index/commands.ts b/src/renderer/pages/index/commands.ts index 996e48cf..e24e85ba 100644 --- a/src/renderer/pages/index/commands.ts +++ b/src/renderer/pages/index/commands.ts @@ -145,46 +145,6 @@ const deleteTask = () => { }); }; -const moveTaskUp = () => { - getTaskStore() - .moveSelectedTasks("up", { - onSyncError: () => { - toast.error("Syncing priority failed", { - duration: 1800, - }); - }, - }) - .then((movedCount) => { - if (movedCount === 0) { - return; - } - toast.success(i18n.t("task.move-task-up")); - }) - .catch(() => { - toast.error(i18n.t("task.move-task-up")); - }); -}; - -const moveTaskDown = () => { - getTaskStore() - .moveSelectedTasks("down", { - onSyncError: () => { - toast.error("Syncing priority failed", { - duration: 1800, - }); - }, - }) - .then((movedCount) => { - if (movedCount === 0) { - return; - } - toast.success(i18n.t("task.move-task-down")); - }) - .catch(() => { - toast.error(i18n.t("task.move-task-down")); - }); -}; - const pauseAllTask = () => { getTaskStore().pauseAllTask(); }; @@ -219,8 +179,6 @@ commands.register("application:new-bt-task-with-file", showAddBtTaskWithFile); commands.register("application:pause-task", pauseTask); commands.register("application:resume-task", resumeTask); commands.register("application:delete-task", deleteTask); -commands.register("application:move-task-up", moveTaskUp); -commands.register("application:move-task-down", moveTaskDown); commands.register("application:pause-all-task", pauseAllTask); commands.register("application:resume-all-task", resumeAllTask); commands.register("application:select-all-task", selectAllTask); diff --git a/src/renderer/pages/index/main.ts b/src/renderer/pages/index/main.ts index 7c4e2f6b..6e4419dd 100644 --- a/src/renderer/pages/index/main.ts +++ b/src/renderer/pages/index/main.ts @@ -83,8 +83,6 @@ const updateAppMenuLabels = (i18n: { t: (key: string) => string }) => { "pause-task": i18n.t("task.pause-task"), "resume-task": i18n.t("task.resume-task"), "delete-task": i18n.t("task.delete-task"), - "move-task-up": i18n.t("task.move-task-up"), - "move-task-down": i18n.t("task.move-task-down"), "pause-all-task": i18n.t("task.pause-all-task"), "resume-all-task": i18n.t("task.resume-all-task"), "select-all-task": i18n.t("task.select-all-task"), diff --git a/src/renderer/store/app.ts b/src/renderer/store/app.ts index 33fbc608..01772c55 100644 --- a/src/renderer/store/app.ts +++ b/src/renderer/store/app.ts @@ -66,6 +66,8 @@ export const useAppStore = defineStore("app", { numStopped: 0, }, addTaskVisible: false, + missedScheduledVisible: false, + missedScheduledTasks: [] as DownloadTask[], addTaskType: ADD_TASK_TYPE.URI, addTaskUrl: "", addTaskTorrents: [] as { name: string; path: string }[], @@ -159,6 +161,14 @@ export const useAppStore = defineStore("app", { this.addTaskTorrents = []; this.addTaskQueue = []; }, + showMissedScheduled(tasks: DownloadTask[]) { + this.missedScheduledTasks = tasks; + this.missedScheduledVisible = true; + }, + hideMissedScheduled() { + this.missedScheduledVisible = false; + this.missedScheduledTasks = []; + }, showCloudflareDialog(payload: { gid: string; host: string; diff --git a/src/renderer/store/batchQueue.ts b/src/renderer/store/batchQueue.ts index 5cb67106..22931951 100644 --- a/src/renderer/store/batchQueue.ts +++ b/src/renderer/store/batchQueue.ts @@ -82,15 +82,10 @@ const createFileBatchItem = ( }; }; -export const createTorrentBatchItem = ( - path: string, - name?: string, -): BatchQueueItem => createFileBatchItem("torrent", "idle", path, name); +const createTorrentBatchItem = (path: string, name?: string): BatchQueueItem => + createFileBatchItem("torrent", "idle", path, name); -export const createMetalinkBatchItem = ( - path: string, - name?: string, -): BatchQueueItem => +const createMetalinkBatchItem = (path: string, name?: string): BatchQueueItem => createFileBatchItem("metalink", "preview-disabled", path, name); const TORRENT_EXTS = ["torrent"]; diff --git a/src/renderer/store/task.ts b/src/renderer/store/task.ts index 993b139f..2273a927 100644 --- a/src/renderer/store/task.ts +++ b/src/renderer/store/task.ts @@ -1,10 +1,5 @@ import { TASK_STATUS } from "@shared/constants"; -import type { - DownloadFile, - DownloadTask, - PeerInfo, - SyncOrderResult, -} from "@shared/types/task"; +import type { DownloadFile, DownloadTask, PeerInfo } from "@shared/types/task"; import { calcProgress, checkTaskIsBT, getTaskName } from "@shared/utils"; import logger from "@shared/utils/logger"; import { defineStore } from "pinia"; @@ -139,6 +134,7 @@ export const useTaskStore = defineStore("task", { all: [] as string[], active: [] as string[], waiting: [] as string[], + scheduled: [] as string[], completed: [] as string[], stopped: [] as string[], }, @@ -146,6 +142,7 @@ export const useTaskStore = defineStore("task", { all: 0, active: 0, waiting: 0, + scheduled: 0, completed: 0, stopped: 0, } as Record, @@ -158,6 +155,7 @@ export const useTaskStore = defineStore("task", { all: 1, active: 1, waiting: 1, + scheduled: 1, completed: 1, stopped: 1, }, @@ -413,37 +411,31 @@ export const useTaskStore = defineStore("task", { let activeCount = numActive; let waitingCount = numWaiting; + let scheduledCount = this.taskCountMap.scheduled || 0; let completedCount = this.taskCountMap.completed || 0; let stoppedCount = this.taskCountMap.stopped || 0; let allCount = numActive + numWaiting + numStoppedTotal; - // Fetch small projections so sidebar counts match visible rows - // Skip the fetch when aria2 says there is nothing to count const needFetch = numActive + numWaiting + numStoppedTotal > 0; if (needFetch) { try { - const [activeData, waitingData, stoppedData] = await Promise.all([ - numActive > 0 - ? (api.fetchTaskList({ - type: "active", - keys: ["gid", "status", "files", "bittorrent"], - }) as Promise) - : Promise.resolve([] as DownloadTask[]), - numWaiting > 0 - ? (api.fetchTaskList({ - type: "waiting", - keys: ["gid", "status", "files", "bittorrent"], - }) as Promise) - : Promise.resolve([] as DownloadTask[]), - numStoppedTotal > 0 - ? (api.fetchTaskList({ - type: "stopped", - keys: ["gid", "status", "files", "bittorrent"], - }) as Promise) - : Promise.resolve([] as DownloadTask[]), - ]); + const keys = ["gid", "status", "files", "bittorrent"]; + const empty = Promise.resolve([] as DownloadTask[]); + const fetchSmall = ( + type: "active" | "waiting" | "scheduled" | "stopped", + ) => api.fetchTaskList({ type, keys }) as Promise; + const [activeData, waitingData, scheduledData, stoppedData] = + await Promise.all([ + numActive > 0 ? fetchSmall("active") : empty, + numWaiting > 0 ? fetchSmall("waiting") : empty, + numWaiting > 0 ? fetchSmall("scheduled") : empty, + numStoppedTotal > 0 ? fetchSmall("stopped") : empty, + ]); const activeArr = Array.isArray(activeData) ? activeData : []; const waitingArr = Array.isArray(waitingData) ? waitingData : []; + const scheduledArr = Array.isArray(scheduledData) + ? scheduledData + : []; const stoppedArr = Array.isArray(stoppedData) ? stoppedData : []; const completedArr = stoppedArr.filter( (t) => t.status === TASK_STATUS.COMPLETE, @@ -453,18 +445,26 @@ export const useTaskStore = defineStore("task", { ); activeCount = activeArr.length; waitingCount = waitingArr.length; + scheduledCount = scheduledArr.length; completedCount = completedArr.length; stoppedCount = stoppedOnlyArr.length; - allCount = activeCount + waitingCount + completedCount + stoppedCount; + allCount = + activeCount + + waitingCount + + scheduledCount + + completedCount + + stoppedCount; } catch { // Keep previous counts on failure activeCount = this.taskCountMap.active || 0; waitingCount = this.taskCountMap.waiting || 0; + scheduledCount = this.taskCountMap.scheduled || 0; completedCount = this.taskCountMap.completed || 0; stoppedCount = this.taskCountMap.stopped || 0; allCount = this.taskCountMap.all || 0; } } else { + scheduledCount = 0; completedCount = 0; stoppedCount = 0; } @@ -473,6 +473,7 @@ export const useTaskStore = defineStore("task", { all: allCount, active: activeCount, waiting: waitingCount, + scheduled: scheduledCount, completed: completedCount, stopped: stoppedCount, }; @@ -590,6 +591,11 @@ export const useTaskStore = defineStore("task", { updateCurrentTaskGid(gid: string) { this.currentTaskGid = gid; }, + updateCurrentTaskDetail() { + return this.currentTaskGid + ? this.fetchItemWithPeers(this.currentTaskGid) + : null; + }, addUri(data: { uris: string[]; outs: string[]; @@ -811,109 +817,45 @@ export const useTaskStore = defineStore("task", { this.saveSession(); }); }, - async syncSelectedTaskOrder( - direction: "up" | "down", - selectedGids: string[], - ) { - const selectedGidSet = new Set(selectedGids); - const selectedTasks = this.taskList.filter((task) => - selectedGidSet.has(task.gid), + async reorderTasks(gids: string[], targetGid: string, after: boolean) { + if (gids.length === 0 || !targetGid || gids.includes(targetGid)) { + return; + } + const moveSet = new Set(gids); + const moved = this.taskList.filter((task) => moveSet.has(task.gid)); + const remaining = this.taskList.filter((task) => !moveSet.has(task.gid)); + const targetIdx = remaining.findIndex((task) => task.gid === targetGid); + const insertAt = + targetIdx < 0 ? remaining.length : after ? targetIdx + 1 : targetIdx; + const nextList = [ + ...remaining.slice(0, insertAt), + ...moved, + ...remaining.slice(insertAt), + ]; + this.taskList = nextList; + this.updateTaskOrder( + this.currentList, + nextList.map((task) => task.gid), ); - const selectedTaskPayload = selectedTasks.map((task) => ({ - gid: task.gid, - status: task.status, - })); - try { - const result = (await api.syncSelectedTaskOrder({ - direction, - selectedTasks: selectedTaskPayload, - })) as SyncOrderResult; - const movedValue = Number(result?.moved); - const moved = Number.isFinite(movedValue) ? movedValue : 0; - const partialError = !!result?.partialError; + try { + await api.reorderTasks({ gids, targetGid, after }); await this.fetchList(); this.saveSession(); - - if (partialError) { - const err = Object.assign(new Error("priority-sync-failed"), { - reconciled: true, - }); - throw err; - } - - return moved; } catch (err: unknown) { - if (!(err as { reconciled?: boolean })?.reconciled) { - await this.fetchList(); - this.saveSession(); - } - - throw err; + logger.warn("[Risuko] reorderTasks failed:", (err as Error).message); + await this.fetchList(); } }, - async moveSelectedTasks( - direction: "up" | "down", - options: { onSyncError?: (error: unknown) => void } = {}, - ) { - const { onSyncError } = options; - const selectedGids = this.selectedGids; - if (selectedGids.length === 0) { - return 0; - } - - const selectedSet = new Set(selectedGids); - const nextList = [...this.taskList]; - let moved = 0; - - if (direction === "up") { - for (let i = 1; i < nextList.length; i += 1) { - const curr = nextList[i]; - const prev = nextList[i - 1]; - if (!selectedSet.has(curr.gid) || selectedSet.has(prev.gid)) { - continue; - } - nextList[i - 1] = curr; - nextList[i] = prev; - moved += 1; - } - } else { - for (let i = nextList.length - 2; i >= 0; i -= 1) { - const curr = nextList[i]; - const next = nextList[i + 1]; - if (!selectedSet.has(curr.gid) || selectedSet.has(next.gid)) { - continue; - } - nextList[i + 1] = curr; - nextList[i] = next; - moved += 1; - } - } - - if (moved === 0) { - return 0; - } - - this.taskList = nextList; - this.updateTaskOrder( - this.currentList, - nextList.map((task) => task.gid), - ); + async setSchedule(gid: string, startAt: number) { + await api.setTaskSchedule({ gid, startAt }); + await this.fetchList(); + this.saveSession(); + }, + async startNow(gid: string) { + await api.startTaskNow({ gid }); + await this.fetchList(); this.saveSession(); - - this.syncSelectedTaskOrder(direction, selectedGids).catch( - (err: unknown) => { - logger.warn( - "[Risuko] syncSelectedTaskOrder failed:", - (err as Error).message, - ); - if (typeof onSyncError === "function") { - onSyncError(err); - } - }, - ); - - return moved; }, }, }); diff --git a/src/renderer/styles/components/task.css b/src/renderer/styles/components/task.css index a617d814..99df0d4f 100644 --- a/src/renderer/styles/components/task.css +++ b/src/renderer/styles/components/task.css @@ -95,7 +95,7 @@ height: 64px; box-sizing: border-box; padding: 0 8px; - margin: 0 -8px; + margin: 0 -8px 10px; border-bottom: 1px solid var(--border); background: transparent; transition: background-color var(--dur-1) var(--ease-out); @@ -151,6 +151,9 @@ .task-status-dot.status-seeding { background: #a78bfa; } + .task-status-dot.status-scheduled { + background: #38bdf8; + } .task-name { flex: 1; min-width: 0; @@ -603,4 +606,47 @@ background: rgba(239, 68, 68, 0.24); border-color: rgba(239, 68, 68, 0.42); } + .tag-task-status--scheduled { + color: #075985; + background: rgba(56, 189, 248, 0.14); + border-color: rgba(56, 189, 248, 0.34); + } + .dark .tag-task-status--scheduled { + color: #7dd3fc; + background: rgba(56, 189, 248, 0.2); + border-color: rgba(56, 189, 248, 0.42); + } + + .task-drag-handle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + color: var(--text-2); + cursor: grab; + opacity: 0; + touch-action: none; + transition: opacity 0.15s ease; + } + .task-item:hover .task-drag-handle, + .task-item.selected .task-drag-handle { + opacity: 0.6; + } + .task-drag-handle:hover { + opacity: 1; + } + .task-dragging { + cursor: grabbing; + user-select: none; + } + [attr].drop-before { + box-shadow: inset 0 2px 0 0 #3b82f6; + } + [attr].drop-after { + box-shadow: inset 0 -2px 0 0 #3b82f6; + } + .task-item.has-drag-handle .task-card-progress, + .task-item.has-drag-handle .task-files { + padding-left: 24px; + } } diff --git a/src/renderer/utils/task.ts b/src/renderer/utils/task.ts index 4b2712d4..0abb6541 100644 --- a/src/renderer/utils/task.ts +++ b/src/renderer/utils/task.ts @@ -42,6 +42,7 @@ export interface TaskForm { completionScriptCommand: string; completionScriptArgs: string; completionScriptTimeoutMs: number; + startAt: number | null; [key: string]: unknown; } @@ -89,6 +90,7 @@ export const initTaskForm = (state: TaskFormState) => { completionScriptCommand: "", completionScriptArgs: "", completionScriptTimeoutMs: 30000, + startAt: null, ...addTaskOptions, }; return result; @@ -188,11 +190,14 @@ export const buildOption = (type: string, form: TaskForm) => { result["risuko-completion-script-timeout-ms"] = Number(form.completionScriptTimeoutMs) || 30000; } else { - // Override toggled on but no command — explicitly disable per task result["risuko-completion-script-enabled"] = false; } } + if (form.startAt && form.startAt > 0) { + result["risuko-start-at"] = form.startAt; + } + return result; }; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index c05450ad..b8d41d4d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -21,6 +21,7 @@ export const TASK_STATUS = { ACTIVE: "active", WAITING: "waiting", PAUSED: "paused", + SCHEDULED: "scheduled", ERROR: "error", COMPLETE: "complete", REMOVED: "removed", diff --git a/src/shared/locales/en-US/task.ts b/src/shared/locales/en-US/task.ts index c3621354..c87ccd57 100644 --- a/src/shared/locales/en-US/task.ts +++ b/src/shared/locales/en-US/task.ts @@ -2,8 +2,25 @@ export default { all: "All", active: "Downloading", waiting: "Waiting", + scheduled: "Scheduled", stopped: "Stopped", completed: "Completed", + "start-now": "Start Now", + "schedule-task": "Schedule…", + "reschedule-task": "Reschedule…", + "schedule-dialog-title": "Schedule Download", + "schedule-dialog-hint": + "The download stays paused and starts automatically at the time you pick.", + "schedule-pick-time": "Pick a start time", + "schedule-confirm": "Schedule", + "schedule-start-at": "Time", + "schedule-start-at-placeholder": "Not scheduled — starts immediately", + "starting-after": "Starting after {{duration}}", + "missed-schedule-title": "Missed scheduled downloads", + "missed-schedule-hint": + "These downloads were due while the app was closed. Start them now, or reschedule them from the Scheduled tab.", + "missed-schedule-dismiss": "Dismiss", + "missed-schedule-start-all": "Start all", "new-task": "New Task", "new-bt-task": "New BT Task", "open-file": "Open Torrent File...", diff --git a/src/shared/locales/zh-CN/task.ts b/src/shared/locales/zh-CN/task.ts index d0c43c82..0fd3817a 100644 --- a/src/shared/locales/zh-CN/task.ts +++ b/src/shared/locales/zh-CN/task.ts @@ -2,8 +2,24 @@ export default { all: "全部", active: "下载中", waiting: "等待中", + scheduled: "计划中", stopped: "已停止", completed: "已完成", + "start-now": "立即开始", + "schedule-task": "计划开始…", + "reschedule-task": "重新计划…", + "schedule-dialog-title": "计划下载", + "schedule-dialog-hint": "任务将保持暂停,并在所选时间自动开始。", + "schedule-pick-time": "选择开始时间", + "schedule-confirm": "计划", + "schedule-start-at": "时间", + "schedule-start-at-placeholder": "未计划 — 立即开始", + "starting-after": "{{duration}}后开始", + "missed-schedule-title": "错过的计划下载", + "missed-schedule-hint": + "这些下载在应用关闭时已到期。可立即开始,或在“计划中”标签重新计划。", + "missed-schedule-dismiss": "忽略", + "missed-schedule-start-all": "全部开始", "new-task": "新建任务", "new-bt-task": "新建 BT 任务", "open-file": "打开种子文件...", diff --git a/src/shared/locales/zh-TW/task.ts b/src/shared/locales/zh-TW/task.ts index 1a2f0221..777d1ea6 100644 --- a/src/shared/locales/zh-TW/task.ts +++ b/src/shared/locales/zh-TW/task.ts @@ -2,8 +2,24 @@ export default { all: "全部", active: "下載中", waiting: "等待中", + scheduled: "排程中", stopped: "已停止", completed: "已完成", + "start-now": "立即開始", + "schedule-task": "排程開始…", + "reschedule-task": "重新排程…", + "schedule-dialog-title": "排程下載", + "schedule-dialog-hint": "任務將保持暫停,並在所選時間自動開始。", + "schedule-pick-time": "選擇開始時間", + "schedule-confirm": "排程", + "schedule-start-at": "時間", + "schedule-start-at-placeholder": "未排程 — 立即開始", + "starting-after": "{{duration}}後開始", + "missed-schedule-title": "錯過的排程下載", + "missed-schedule-hint": + "這些下載在應用程式關閉時已到期。可立即開始,或在「排程中」分頁重新排程。", + "missed-schedule-dismiss": "忽略", + "missed-schedule-start-all": "全部開始", "new-task": "新增任務", "new-bt-task": "新增 BT 任務", "open-file": "開啟種子檔案...", diff --git a/src/shared/syncCategories.ts b/src/shared/syncCategories.ts index aaaf810f..e2bfeaf4 100644 --- a/src/shared/syncCategories.ts +++ b/src/shared/syncCategories.ts @@ -5,7 +5,7 @@ export interface SyncCategory { keys: string[]; } -export const MISC_CATEGORY = "misc"; +const MISC_CATEGORY = "misc"; const DEVICE_LOCAL_KEYS = new Set([ "cloud-sync-enabled", diff --git a/src/shared/types/task.ts b/src/shared/types/task.ts index b6a5b561..6685e2a4 100644 --- a/src/shared/types/task.ts +++ b/src/shared/types/task.ts @@ -35,6 +35,8 @@ export interface DownloadTask { errorCode?: string; errorMessage?: string; createdAt?: string; + startAt?: string; + scheduleMissed?: boolean; infoHash?: string; infoHashV2?: string; metaVersion?: "v1" | "v2" | "hybrid"; @@ -86,11 +88,6 @@ export interface AutoRetryPlanResult { delayMs: number; } -export interface SyncOrderResult { - moved: number; - partialError: boolean; -} - export interface MediaFormat { format_id: string; ext: string; diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 43f749db..e0a2fa14 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -620,14 +620,10 @@ const isFtpsLink = (uri: string): boolean => { return uri.trim().toLowerCase().startsWith("ftps://"); }; -export const isSftpLink = (uri: string): boolean => { +const isSftpLink = (uri: string): boolean => { return uri.trim().toLowerCase().startsWith("sftp://"); }; -export const isFtpFamily = (uri: string): boolean => { - return isFtpLink(uri) || isFtpsLink(uri) || isSftpLink(uri); -}; - export const getLangDirection = (locale = "en-US") => { return SUPPORT_RTL_LOCALES.includes(locale) ? "rtl" : "ltr"; };