Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src-tauri/src/ureq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions src/celemod-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
39 changes: 30 additions & 9 deletions src/celemod-ui/src/components/DownloadList.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -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 (
<div className="download-page">
Expand All @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion src/celemod-ui/src/components/ModList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 74 additions & 0 deletions src/celemod-ui/src/downloadTasks.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
63 changes: 63 additions & 0 deletions src/celemod-ui/src/downloadTasks.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading