Bughunt: smooth UI actions and skip redundant settings updates
This commit is contained in:
parent
6b65da7f66
commit
c83fa3b86a
@ -70,11 +70,17 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||||||
this.settings = normalizeSettings({
|
const nextSettings = normalizeSettings({
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
...this.settings,
|
...this.settings,
|
||||||
...partial
|
...partial
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (JSON.stringify(nextSettings) === JSON.stringify(this.settings)) {
|
||||||
|
return this.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settings = nextSettings;
|
||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
this.manager.setSettings(this.settings);
|
this.manager.setSettings(this.settings);
|
||||||
return this.settings;
|
return this.settings;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { DragEvent, KeyboardEvent, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { DragEvent, KeyboardEvent, ReactElement, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { AppSettings, AppTheme, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStats, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types";
|
import type { AppSettings, AppTheme, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStats, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types";
|
||||||
|
|
||||||
type Tab = "collector" | "downloads" | "settings";
|
type Tab = "collector" | "downloads" | "settings";
|
||||||
@ -66,6 +66,8 @@ export function App(): ReactElement {
|
|||||||
const [tab, setTab] = useState<Tab>("collector");
|
const [tab, setTab] = useState<Tab>("collector");
|
||||||
const [statusToast, setStatusToast] = useState("");
|
const [statusToast, setStatusToast] = useState("");
|
||||||
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
|
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
|
||||||
|
const [settingsDirty, setSettingsDirty] = useState(false);
|
||||||
|
const settingsDirtyRef = useRef(false);
|
||||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||||
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@ -81,7 +83,9 @@ export function App(): ReactElement {
|
|||||||
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||||
const [downloadSearch, setDownloadSearch] = useState("");
|
const [downloadSearch, setDownloadSearch] = useState("");
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
const actionBusyRef = useRef(false);
|
||||||
const dragOverRef = useRef(false);
|
const dragOverRef = useRef(false);
|
||||||
|
const dragDepthRef = useRef(0);
|
||||||
|
|
||||||
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
||||||
|
|
||||||
@ -89,6 +93,10 @@ export function App(): ReactElement {
|
|||||||
activeCollectorTabRef.current = activeCollectorTab;
|
activeCollectorTabRef.current = activeCollectorTab;
|
||||||
}, [activeCollectorTab]);
|
}, [activeCollectorTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
settingsDirtyRef.current = settingsDirty;
|
||||||
|
}, [settingsDirty]);
|
||||||
|
|
||||||
const showToast = (message: string, timeoutMs = 2200): void => {
|
const showToast = (message: string, timeoutMs = 2200): void => {
|
||||||
setStatusToast(message);
|
setStatusToast(message);
|
||||||
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
||||||
@ -104,6 +112,7 @@ export function App(): ReactElement {
|
|||||||
void window.rd.getSnapshot().then((state) => {
|
void window.rd.getSnapshot().then((state) => {
|
||||||
setSnapshot(state);
|
setSnapshot(state);
|
||||||
setSettingsDraft(state.settings);
|
setSettingsDraft(state.settings);
|
||||||
|
setSettingsDirty(false);
|
||||||
applyTheme(state.settings.theme);
|
applyTheme(state.settings.theme);
|
||||||
if (state.settings.autoUpdateCheck) {
|
if (state.settings.autoUpdateCheck) {
|
||||||
void window.rd.checkUpdates().then((result) => {
|
void window.rd.checkUpdates().then((result) => {
|
||||||
@ -119,7 +128,11 @@ export function App(): ReactElement {
|
|||||||
stateFlushTimerRef.current = setTimeout(() => {
|
stateFlushTimerRef.current = setTimeout(() => {
|
||||||
stateFlushTimerRef.current = null;
|
stateFlushTimerRef.current = null;
|
||||||
if (latestStateRef.current) {
|
if (latestStateRef.current) {
|
||||||
setSnapshot(latestStateRef.current);
|
const next = latestStateRef.current;
|
||||||
|
setSnapshot(next);
|
||||||
|
if (!settingsDirtyRef.current) {
|
||||||
|
setSettingsDraft(next.settings);
|
||||||
|
}
|
||||||
latestStateRef.current = null;
|
latestStateRef.current = null;
|
||||||
}
|
}
|
||||||
}, 220);
|
}, 220);
|
||||||
@ -145,23 +158,28 @@ export function App(): ReactElement {
|
|||||||
.map((id: string) => snapshot.session.packages[id])
|
.map((id: string) => snapshot.session.packages[id])
|
||||||
.filter(Boolean), [snapshot]);
|
.filter(Boolean), [snapshot]);
|
||||||
|
|
||||||
|
const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCollapsedPackages((prev) => {
|
setCollapsedPackages((prev) => {
|
||||||
const next: Record<string, boolean> = {};
|
const next: Record<string, boolean> = {};
|
||||||
for (const pkg of packages) {
|
const defaultCollapsed = snapshot.session.packageOrder.length >= 24;
|
||||||
next[pkg.id] = prev[pkg.id] ?? false;
|
for (const packageId of snapshot.session.packageOrder) {
|
||||||
|
next[packageId] = prev[packageId] ?? defaultCollapsed;
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [packages]);
|
}, [packageOrderKey, snapshot.session.packageOrder.length]);
|
||||||
|
|
||||||
|
const deferredDownloadSearch = useDeferredValue(downloadSearch);
|
||||||
|
|
||||||
const filteredPackages = useMemo(() => {
|
const filteredPackages = useMemo(() => {
|
||||||
const query = downloadSearch.trim().toLowerCase();
|
const query = deferredDownloadSearch.trim().toLowerCase();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
return packages.filter((pkg) => pkg.name.toLowerCase().includes(query));
|
return packages.filter((pkg) => pkg.name.toLowerCase().includes(query));
|
||||||
}, [packages, downloadSearch]);
|
}, [packages, deferredDownloadSearch]);
|
||||||
|
|
||||||
const allPackagesCollapsed = useMemo(() => (
|
const allPackagesCollapsed = useMemo(() => (
|
||||||
packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id])
|
packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id])
|
||||||
@ -250,6 +268,7 @@ export function App(): ReactElement {
|
|||||||
try {
|
try {
|
||||||
const result = await window.rd.updateSettings(normalizedSettingsDraft);
|
const result = await window.rd.updateSettings(normalizedSettingsDraft);
|
||||||
setSettingsDraft(result);
|
setSettingsDraft(result);
|
||||||
|
setSettingsDirty(false);
|
||||||
applyTheme(result.theme);
|
applyTheme(result.theme);
|
||||||
showToast("Einstellungen gespeichert", 1800);
|
showToast("Einstellungen gespeichert", 1800);
|
||||||
} catch (error) { showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800); }
|
} catch (error) { showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800); }
|
||||||
@ -285,6 +304,7 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => {
|
const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
dragDepthRef.current = 0;
|
||||||
dragOverRef.current = false;
|
dragOverRef.current = false;
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const files = Array.from(event.dataTransfer.files ?? []) as File[];
|
const files = Array.from(event.dataTransfer.files ?? []) as File[];
|
||||||
@ -338,18 +358,29 @@ export function App(): ReactElement {
|
|||||||
} catch (error) { showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); }
|
} catch (error) { showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const setBool = (key: keyof AppSettings, value: boolean): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
|
const setBool = (key: keyof AppSettings, value: boolean): void => {
|
||||||
const setText = (key: keyof AppSettings, value: string): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
|
setSettingsDirty(true);
|
||||||
const setNum = (key: keyof AppSettings, value: number): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
|
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
const setText = (key: keyof AppSettings, value: string): void => {
|
||||||
|
setSettingsDirty(true);
|
||||||
|
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
const setNum = (key: keyof AppSettings, value: number): void => {
|
||||||
|
setSettingsDirty(true);
|
||||||
|
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
const setSpeedLimitMbps = (value: number): void => {
|
const setSpeedLimitMbps = (value: number): void => {
|
||||||
const mbps = Number.isFinite(value) ? Math.max(0, value) : 0;
|
const mbps = Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||||
|
setSettingsDirty(true);
|
||||||
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
|
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const performQuickAction = async (action: () => Promise<unknown>): Promise<void> => {
|
const performQuickAction = async (action: () => Promise<unknown>): Promise<void> => {
|
||||||
if (actionBusy) {
|
if (actionBusyRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
actionBusyRef.current = true;
|
||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
@ -357,8 +388,9 @@ export function App(): ReactElement {
|
|||||||
showToast(`Fehler: ${String(error)}`, 2600);
|
showToast(`Fehler: ${String(error)}`, 2600);
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
actionBusyRef.current = false;
|
||||||
setActionBusy(false);
|
setActionBusy(false);
|
||||||
}, 100);
|
}, 80);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -464,15 +496,20 @@ export function App(): ReactElement {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`app-shell${dragOver ? " drag-over" : ""}`}
|
className={`app-shell${dragOver ? " drag-over" : ""}`}
|
||||||
onDragOver={(e) => {
|
onDragEnter={(event) => {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
|
dragDepthRef.current += 1;
|
||||||
if (!dragOverRef.current) {
|
if (!dragOverRef.current) {
|
||||||
dragOverRef.current = true;
|
dragOverRef.current = true;
|
||||||
setDragOver(true);
|
setDragOver(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
onDragLeave={() => {
|
onDragLeave={() => {
|
||||||
if (dragOverRef.current) {
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||||
|
if (dragDepthRef.current === 0 && dragOverRef.current) {
|
||||||
dragOverRef.current = false;
|
dragOverRef.current = false;
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
}
|
}
|
||||||
@ -648,6 +685,7 @@ export function App(): ReactElement {
|
|||||||
<button className="btn" onClick={onCheckUpdates}>Updates prüfen</button>
|
<button className="btn" onClick={onCheckUpdates}>Updates prüfen</button>
|
||||||
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
|
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
|
||||||
const next = settingsDraft.theme === "dark" ? "light" : "dark";
|
const next = settingsDraft.theme === "dark" ? "light" : "dark";
|
||||||
|
setSettingsDirty(true);
|
||||||
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
|
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
|
||||||
applyTheme(next as AppTheme);
|
applyTheme(next as AppTheme);
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@ -500,7 +500,7 @@ describe("download manager", () => {
|
|||||||
fs.mkdirSync(pkgDir, { recursive: true });
|
fs.mkdirSync(pkgDir, { recursive: true });
|
||||||
const existingTargetPath = path.join(pkgDir, "resume.mkv");
|
const existingTargetPath = path.join(pkgDir, "resume.mkv");
|
||||||
fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize));
|
fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize));
|
||||||
let seenRangeStart = -1;
|
let sawResumeRange = false;
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
if ((req.url || "") !== "/resume") {
|
if ((req.url || "") !== "/resume") {
|
||||||
@ -512,7 +512,9 @@ describe("download manager", () => {
|
|||||||
const range = String(req.headers.range || "");
|
const range = String(req.headers.range || "");
|
||||||
const match = range.match(/bytes=(\d+)-/i);
|
const match = range.match(/bytes=(\d+)-/i);
|
||||||
const start = match ? Number(match[1]) : 0;
|
const start = match ? Number(match[1]) : 0;
|
||||||
seenRangeStart = start;
|
if (start === partialSize) {
|
||||||
|
sawResumeRange = true;
|
||||||
|
}
|
||||||
const chunk = binary.subarray(start);
|
const chunk = binary.subarray(start);
|
||||||
|
|
||||||
if (start > 0) {
|
if (start > 0) {
|
||||||
@ -611,7 +613,7 @@ describe("download manager", () => {
|
|||||||
const item = manager.getSnapshot().session.items[itemId];
|
const item = manager.getSnapshot().session.items[itemId];
|
||||||
expect(item?.status).toBe("completed");
|
expect(item?.status).toBe("completed");
|
||||||
expect(item?.targetPath).toBe(existingTargetPath);
|
expect(item?.targetPath).toBe(existingTargetPath);
|
||||||
expect(seenRangeStart).toBe(partialSize);
|
expect(sawResumeRange).toBe(true);
|
||||||
expect(fs.statSync(existingTargetPath).size).toBe(binary.length);
|
expect(fs.statSync(existingTargetPath).size).toBe(binary.length);
|
||||||
} finally {
|
} finally {
|
||||||
server.close();
|
server.close();
|
||||||
@ -1945,6 +1947,105 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles rapid pause/resume toggles without deadlock", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const binary = Buffer.alloc(320 * 1024, 11);
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if ((req.url || "") !== "/toggle-stress") {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not-found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = String(req.headers.range || "");
|
||||||
|
const match = range.match(/bytes=(\d+)-/i);
|
||||||
|
const start = match ? Number(match[1]) : 0;
|
||||||
|
const chunk = binary.subarray(start);
|
||||||
|
|
||||||
|
if (start > 0) {
|
||||||
|
res.statusCode = 206;
|
||||||
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
||||||
|
} else {
|
||||||
|
res.statusCode = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Length", String(chunk.length));
|
||||||
|
const split = Math.max(1, Math.floor(chunk.length / 4));
|
||||||
|
res.write(chunk.subarray(0, split));
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.end(chunk.subarray(split));
|
||||||
|
}
|
||||||
|
}, 260);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("server address unavailable");
|
||||||
|
}
|
||||||
|
const directUrl = `http://127.0.0.1:${address.port}/toggle-stress`;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
download: directUrl,
|
||||||
|
filename: "stress.part01.rar",
|
||||||
|
filesize: binary.length
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false,
|
||||||
|
maxParallel: 1
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.addPackages([{ name: "toggle-stress", links: ["https://dummy/toggle-stress"] }]);
|
||||||
|
manager.start();
|
||||||
|
|
||||||
|
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.status === "downloading", 12000);
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
manager.togglePause();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 90));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manager.getSnapshot().session.paused) {
|
||||||
|
manager.togglePause();
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||||
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
expect(item?.status).toBe("completed");
|
||||||
|
expect(fs.existsSync(item?.targetPath || "")).toBe(true);
|
||||||
|
expect(fs.statSync(item?.targetPath || "").size).toBe(binary.length);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps active downloads resumable on shutdown preparation", async () => {
|
it("keeps active downloads resumable on shutdown preparation", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user