Bughunt: smooth UI actions and skip redundant settings updates

This commit is contained in:
Sucukdeluxe 2026-02-27 17:15:20 +01:00
parent 6b65da7f66
commit c83fa3b86a
3 changed files with 164 additions and 19 deletions

View File

@ -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;

View File

@ -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);
}}> }}>

View File

@ -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);