diff --git a/src-tauri/src/ureq.rs b/src-tauri/src/ureq.rs
index 3bca6eb..be1ed36 100644
--- a/src-tauri/src/ureq.rs
+++ b/src-tauri/src/ureq.rs
@@ -678,6 +678,19 @@ mod tests {
assert_eq!(parse_content_range("invalid"), None);
}
+ #[test]
+ fn download_agent_bounds_every_network_wait() {
+ // 原来的 agent 没有任何超时:连接建好之后服务端不再发数据时,`read` 会一直
+ // 阻塞——既不报错、也不重试,分段下载里的 `join` 永远不返回,界面上的进度
+ // 就停在原地。这里锁住 PR #84 加上的超时,防止以后被悄悄改回去。
+ for url in ["https://example.com/x", "http://127.0.0.1:1/x"] {
+ let debug = format!("{:?}", super::download_agent(url));
+ assert!(debug.contains("timeout_read: Some(30s)"), "{debug}");
+ assert!(debug.contains("timeout_write: Some(30s)"), "{debug}");
+ assert!(debug.contains("timeout_connect: Some(30s)"), "{debug}");
+ }
+ }
+
const MIB: f64 = 1024.0 * 1024.0;
#[test]
diff --git a/src/celemod-ui/package.json b/src/celemod-ui/package.json
index cbed3c0..df6c014 100644
--- a/src/celemod-ui/package.json
+++ b/src/celemod-ui/package.json
@@ -16,6 +16,7 @@
"test:catalog-download": "node --import tsx --test src/catalogDownload.test.ts",
"test:crash-trace": "node --import tsx --test src/components/crashTrace.test.ts",
"test:deep-link": "node --import tsx --test src/deepLink.test.ts",
+ "test:download-tasks": "node --import tsx --test src/downloadTasks.test.ts",
"test:mod-updates": "node --import tsx --test src/modUpdates.test.ts",
"test:update-notes": "node --import tsx --test src/components/UpdateNotes.test.tsx"
},
diff --git a/src/celemod-ui/src/components/DownloadList.tsx b/src/celemod-ui/src/components/DownloadList.tsx
index aa917fe..86f341c 100644
--- a/src/celemod-ui/src/components/DownloadList.tsx
+++ b/src/celemod-ui/src/components/DownloadList.tsx
@@ -1,8 +1,9 @@
import _i18n from "src/i18n";
import "./DownloadList.scss";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { Icon } from "./Icon";
+import { isDownloadRowVisible, nextDownloadRowExpiry } from "../downloadTasks";
import { Download, useDownloadStore } from "../stores/download";
const formatBytes = (bytes: number) => {
@@ -202,7 +203,7 @@ export const DownloadTask = ({
const canControl =
allowControl && displayTask.state === "pending" && !displayTask.canceled;
const action =
- allowRetry && task.state === "failed" && task.source
+ allowRetry && (task.state === "failed" || task.canceled) && task.source
? {
icon: "replay",
onClick: () => downloadMod(task.name, task.source!, { force: true }),
@@ -308,17 +309,37 @@ export const DownloadTask = ({
);
};
+/** Re-renders once the next finished row leaves its grace period. */
+const useFinishedRowClock = (tasks: Download.TaskInfo[]) => {
+ const [now, setNow] = useState(() => Date.now());
+ const expiry = nextDownloadRowExpiry(tasks, now);
+ useEffect(() => {
+ if (expiry === null) return;
+ const timer = setTimeout(
+ () => setNow(Date.now()),
+ Math.max(0, expiry - Date.now()) + 50,
+ );
+ return () => clearTimeout(timer);
+ }, [expiry]);
+ return now;
+};
+
export const DownloadListPage = () => {
const downloadTasks = useDownloadStore((state) => state.tasks);
const allTasks = Object.values(downloadTasks);
+ const now = useFinishedRowClock(allTasks);
+ /* Finished rows retire on their own once the user has seen them finish.
+ Leaving them in the list made the page disagree with the sidebar badge and
+ with itself (a 100% row next to "0 项") until the app was restarted. */
+ const visibleTasks = allTasks.filter((task) =>
+ isDownloadRowVisible(task, now),
+ );
const taskMap = new Map(
- allTasks.map((task) => [task.name.toLocaleLowerCase(), task]),
+ visibleTasks.map((task) => [task.name.toLocaleLowerCase(), task]),
);
- const roots = allTasks.filter((task) => task.requested);
- const visibleRoots = roots.length > 0 ? roots : allTasks;
- const visibleCount = allTasks.filter(
- (task) => task.state !== "finished" || task.canceled,
- ).length;
+ const roots = visibleTasks.filter((task) => task.requested);
+ const visibleRoots = roots.length > 0 ? roots : visibleTasks;
+ const visibleCount = visibleTasks.length;
return (
@@ -345,7 +366,7 @@ export const DownloadListPage = () => {
const dependencies = collectDependencies(task, taskMap);
return dependencies.length > 0
? dependencies
- : allTasks.filter(
+ : visibleTasks.filter(
(candidate) =>
!candidate.requested &&
candidate.cancelKey === task.cancelKey,
diff --git a/src/celemod-ui/src/components/ModList.tsx b/src/celemod-ui/src/components/ModList.tsx
index 92d4114..189b4ad 100644
--- a/src/celemod-ui/src/components/ModList.tsx
+++ b/src/celemod-ui/src/components/ModList.tsx
@@ -215,7 +215,13 @@ export const Mod = memo(
};
if (downloadTask) {
- if (downloadTask.state === "failed" && downloadTask.source) {
+ // A failed or canceled task must be restartable from here; a
+ // cancellation keeps `state: "pending"`, so it used to fall
+ // through and make the button do nothing.
+ if (
+ (downloadTask.state === "failed" || downloadTask.canceled) &&
+ downloadTask.source
+ ) {
downloadMod(downloadTask.name, downloadTask.source, {
force: true,
autoDisableNewMods,
diff --git a/src/celemod-ui/src/downloadTasks.test.ts b/src/celemod-ui/src/downloadTasks.test.ts
new file mode 100644
index 0000000..7db1f26
--- /dev/null
+++ b/src/celemod-ui/src/downloadTasks.test.ts
@@ -0,0 +1,74 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ FINISHED_ROW_GRACE_MS,
+ downloadAttemptDecision,
+ isDownloadRowVisible,
+ nextDownloadRowExpiry,
+} from "./downloadTasks";
+
+test("starts a new attempt when no task exists or a forced retry is asked", () => {
+ assert.equal(downloadAttemptDecision(undefined, false), "start");
+ assert.equal(downloadAttemptDecision({ state: "pending" }, true), "start");
+ assert.equal(downloadAttemptDecision({ state: "failed" }, false), "start");
+ assert.equal(downloadAttemptDecision({ state: "finished" }, false), "start");
+});
+
+test("attaches to an attempt that is still running", () => {
+ assert.equal(downloadAttemptDecision({ state: "pending" }, false), "attach");
+ assert.equal(
+ downloadAttemptDecision({ state: "pending", canceled: false }, false),
+ "attach",
+ );
+});
+
+test("a canceled attempt can be restarted without force", () => {
+ // The store keeps `state: "pending"` after a cancel, so this used to be
+ // treated as "already downloading" and the request was dropped.
+ assert.equal(
+ downloadAttemptDecision({ state: "pending", canceled: true }, false),
+ "start",
+ );
+});
+
+test("finished rows disappear after the grace period", () => {
+ const now = 1_000_000;
+ const finished = { state: "finished" as const, settledAt: now - 1_000 };
+ assert.equal(isDownloadRowVisible(finished, now), true);
+ assert.equal(
+ isDownloadRowVisible(finished, now + FINISHED_ROW_GRACE_MS),
+ false,
+ );
+ // pending / failed / canceled rows stay, they still need controls.
+ assert.equal(isDownloadRowVisible({ state: "pending" }, now), true);
+ assert.equal(
+ isDownloadRowVisible({ state: "pending", canceled: true }, now),
+ true,
+ );
+ assert.equal(
+ isDownloadRowVisible({ state: "failed", settledAt: now - 10_000 }, now),
+ true,
+ );
+});
+
+test("reports when the next finished row expires", () => {
+ const now = 1_000_000;
+ const fresh = { state: "finished" as const, settledAt: now };
+ const older = { state: "finished" as const, settledAt: now - 1_000 };
+ assert.equal(
+ nextDownloadRowExpiry([{ state: "pending" }, older, fresh], now),
+ older.settledAt + FINISHED_ROW_GRACE_MS,
+ );
+ assert.equal(
+ nextDownloadRowExpiry([fresh], now),
+ now + FINISHED_ROW_GRACE_MS,
+ );
+ assert.equal(nextDownloadRowExpiry([{ state: "pending" }], now), null);
+ assert.equal(
+ nextDownloadRowExpiry(
+ [{ state: "finished", settledAt: now - FINISHED_ROW_GRACE_MS - 1 }],
+ now,
+ ),
+ null,
+ );
+});
diff --git a/src/celemod-ui/src/downloadTasks.ts b/src/celemod-ui/src/downloadTasks.ts
new file mode 100644
index 0000000..efcd392
--- /dev/null
+++ b/src/celemod-ui/src/downloadTasks.ts
@@ -0,0 +1,63 @@
+/* Rules shared by the download store and the Downloads page. They live in a
+ plain module (no store/tauri imports) so they can be unit tested directly. */
+
+export interface DownloadTaskState {
+ state: "finished" | "failed" | "pending";
+ canceled?: boolean;
+ settledAt?: number;
+}
+
+export type DownloadAttemptDecision = "start" | "attach";
+
+/**
+ * What `downloadMod` should do when a task with the same name already exists.
+ *
+ * - `start` – nothing is running for this Mod, run a new attempt.
+ * - `attach` – an attempt is already in flight, so forward its outcome to the
+ * new caller instead of starting a second download of the same
+ * file.
+ *
+ * A canceled task still carries `state: "pending"`, but its download is over:
+ * treating it as "in flight" left buttons that await the returned promise (the
+ * Mod list download button, "下载缺失依赖") disabled with no way to restart.
+ */
+export const downloadAttemptDecision = (
+ existing: DownloadTaskState | undefined,
+ force: boolean,
+): DownloadAttemptDecision => {
+ if (force || !existing) return "start";
+ if (existing.state !== "pending") return "start";
+ if (existing.canceled) return "start";
+ return "attach";
+};
+
+/**
+ * Finished rows are kept for a moment so the user sees the task complete, then
+ * they disappear. Nothing else ever removed them, so a finished download kept
+ * sitting in the Downloads page at 100% (with the sidebar badge already back to
+ * zero) until the app was restarted.
+ */
+export const FINISHED_ROW_GRACE_MS = 4_000;
+
+export const isDownloadRowVisible = (
+ task: DownloadTaskState,
+ now: number,
+ grace = FINISHED_ROW_GRACE_MS,
+) => {
+ if (task.state !== "finished") return true;
+ const settledAt = task.settledAt ?? now;
+ return now - settledAt < grace;
+};
+
+/** Timestamp at which the next finished row falls out of its grace period. */
+export const nextDownloadRowExpiry = (
+ tasks: DownloadTaskState[],
+ now: number,
+ grace = FINISHED_ROW_GRACE_MS,
+): number | null => {
+ const expiries = tasks
+ .filter((task) => task.state === "finished" && task.settledAt !== undefined)
+ .map((task) => task.settledAt! + grace)
+ .filter((expiry) => expiry > now);
+ return expiries.length > 0 ? Math.min(...expiries) : null;
+};
diff --git a/src/celemod-ui/src/stores/download.ts b/src/celemod-ui/src/stores/download.ts
index 13efdbd..cf1dfcb 100644
--- a/src/celemod-ui/src/stores/download.ts
+++ b/src/celemod-ui/src/stores/download.ts
@@ -1,4 +1,5 @@
import { create } from "zustand";
+import { downloadAttemptDecision } from "../downloadTasks";
import {
reloadBlacklistState,
reloadInstalledMods,
@@ -24,6 +25,8 @@ export namespace Download {
canceled?: boolean;
paused?: boolean;
attemptId: number;
+ /** When the task reached `finished`/`failed`, used to retire finished rows. */
+ settledAt?: number;
}
}
@@ -89,6 +92,20 @@ const replaceTasks = (
return next;
};
+/**
+ * Timestamp a task reached a terminal state, carried over as long as it stays
+ * in that state so the Downloads page can retire finished rows on their own.
+ */
+const settledAtFor = (
+ state: Download.TaskInfo["state"],
+ previous: Download.TaskInfo | undefined,
+) => {
+ if (state === "pending") return undefined;
+ if (previous?.state === state && previous.settledAt)
+ return previous.settledAt;
+ return Date.now();
+};
+
const aggregateProgress = (
root: Download.TaskInfo,
byName: Map,
@@ -108,6 +125,39 @@ const aggregateProgress = (
return values.reduce((sum, value) => sum + value, 0) / values.length;
};
+/**
+ * Forward the outcome of an attempt that is already running to a new caller.
+ *
+ * Only the attempt that is still in the store (same `attemptId`) may settle the
+ * promise; if the task is replaced by a newer attempt, or disappears, the
+ * caller is left to that attempt's own callbacks.
+ */
+const attachToRunningAttempt = (
+ task: Download.TaskInfo,
+ callbacks: Pick,
+) => {
+ const key = normalizeName(task.name);
+ let settle = () => {};
+ const unsubscribe = useDownloadStore.subscribe((state) => {
+ const current = state.tasks[key];
+ if (!current || current.attemptId !== task.attemptId) return;
+ if (current.state === "finished") {
+ settle();
+ callbacks.onFinished?.(current);
+ } else if (current.state === "failed" || current.canceled) {
+ const error = current.error || "Download failed";
+ settle();
+ callbacks.onFailed?.(current, error);
+ }
+ });
+ let done = false;
+ settle = () => {
+ if (done) return;
+ done = true;
+ unsubscribe();
+ };
+};
+
export const useDownloadStore = create((set, get) => ({
tasks: {},
@@ -201,32 +251,41 @@ export const useDownloadStore = create((set, get) => ({
reject(new Error(message));
return;
}
- const mapped = backendTasks.map((task, index) => ({
- name: task.name,
- requested: Boolean(task.requested),
- dependencies: task.dependencies ?? [],
- cancelKey: task.cancel_key,
- source: task.url,
- ownerId,
- mod: { name: task.name },
- state: (task.status === "Finished"
- ? "finished"
- : task.status === "Failed"
- ? "failed"
- : "pending") as Download.TaskInfo["state"],
- progress:
+ const mapped = backendTasks.map((task, index) => {
+ const mappedState = (
task.status === "Finished"
- ? 100
- : Number.parseFloat(task.data) || 0,
- error: task.status === "Failed" ? task.data : undefined,
- downloadedBytes: task.downloaded_bytes || 0,
- totalBytes: task.total_bytes || 0,
- speedBytesPerSec: task.speed_bytes_per_sec || 0,
- canceled:
- task.status === "Failed" && task.data === "Download canceled",
- paused: get().tasks[normalizeName(task.name)]?.paused,
- attemptId: attemptId + index,
- }));
+ ? "finished"
+ : task.status === "Failed"
+ ? "failed"
+ : "pending"
+ ) as Download.TaskInfo["state"];
+ return {
+ name: task.name,
+ requested: Boolean(task.requested),
+ dependencies: task.dependencies ?? [],
+ cancelKey: task.cancel_key,
+ source: task.url,
+ ownerId,
+ mod: { name: task.name },
+ state: mappedState,
+ progress:
+ task.status === "Finished"
+ ? 100
+ : Number.parseFloat(task.data) || 0,
+ error: task.status === "Failed" ? task.data : undefined,
+ downloadedBytes: task.downloaded_bytes || 0,
+ totalBytes: task.total_bytes || 0,
+ speedBytesPerSec: task.speed_bytes_per_sec || 0,
+ canceled:
+ task.status === "Failed" && task.data === "Download canceled",
+ paused: get().tasks[normalizeName(task.name)]?.paused,
+ attemptId: attemptId + index,
+ settledAt: settledAtFor(
+ mappedState,
+ get().tasks[normalizeName(task.name)],
+ ),
+ };
+ });
const byName = new Map(
mapped.map((task) => [normalizeName(task.name), task]),
);
@@ -332,8 +391,17 @@ export const useDownloadStore = create((set, get) => ({
return failedTask;
}
- if (existingTask && !force && existingTask.state === "pending")
+ if (
+ downloadAttemptDecision(existingTask, force) === "attach" &&
+ existingTask
+ ) {
+ // An attempt for this Mod is already running: hand this caller the
+ // outcome of that attempt instead of dropping the request. Callers that
+ // await the promise (the Mod list button, "下载缺失依赖") used to stay
+ // disabled forever because nothing ever settled their promise.
+ attachToRunningAttempt(existingTask, { onFinished, onFailed });
return existingTask;
+ }
const attemptId = nextAttemptId++;
const initialTask: Download.TaskInfo = {
@@ -408,6 +476,7 @@ export const useDownloadStore = create((set, get) => ({
task.status === "Failed" && task.data === "Download canceled",
paused: previous?.paused,
attemptId: previous?.attemptId ?? attemptId,
+ settledAt: settledAtFor(taskState, previous),
} satisfies Download.TaskInfo;
});
const byName = new Map(