Release v1.4.30 with startup and UI race-condition fixes
Some checks are pending
Build and Release / build (push) Waiting to run
Some checks are pending
Build and Release / build (push) Waiting to run
This commit is contained in:
parent
eda9754d30
commit
6ae687f3ab
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.29",
|
||||
"version": "1.4.30",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.29",
|
||||
"version": "1.4.30",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.29",
|
||||
"version": "1.4.30",
|
||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -5,7 +5,7 @@ import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
||||
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
|
||||
|
||||
const API_TIMEOUT_MS = 30000;
|
||||
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.29";
|
||||
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.30";
|
||||
const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024;
|
||||
|
||||
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
|
||||
|
||||
@ -343,6 +343,13 @@ function isNoExtractorError(errorText: string): boolean {
|
||||
return String(errorText || "").toLowerCase().includes("nicht gefunden");
|
||||
}
|
||||
|
||||
function isUnsupportedArchiveFormatError(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return text.includes("kein rar-archiv")
|
||||
|| text.includes("not a rar archive")
|
||||
|| text.includes("is not a rar archive");
|
||||
}
|
||||
|
||||
function isUnsupportedExtractorSwitchError(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return text.includes("unknown switch")
|
||||
@ -705,17 +712,26 @@ async function runExternalExtract(
|
||||
}
|
||||
|
||||
function isZipSafetyGuardError(error: unknown): boolean {
|
||||
const text = String(error || "").toLowerCase();
|
||||
return text.includes("path traversal")
|
||||
|| text.includes("zip-eintrag verdächtig groß")
|
||||
|| text.includes("zip-eintrag verdaechtig gross");
|
||||
}
|
||||
|
||||
function isZipInternalLimitError(error: unknown): boolean {
|
||||
const text = String(error || "").toLowerCase();
|
||||
return text.includes("zip-eintrag zu groß")
|
||||
|| text.includes("zip-eintrag komprimiert zu groß")
|
||||
|| text.includes("zip-eintrag ohne sichere groessenangabe")
|
||||
|| text.includes("path traversal");
|
||||
|| text.includes("zip-eintrag ohne sichere groessenangabe");
|
||||
}
|
||||
|
||||
function shouldFallbackToExternalZip(error: unknown): boolean {
|
||||
if (isZipSafetyGuardError(error)) {
|
||||
return false;
|
||||
}
|
||||
if (isZipInternalLimitError(error)) {
|
||||
return true;
|
||||
}
|
||||
const text = String(error || "").toLowerCase();
|
||||
if (text.includes("aborted:extract") || text.includes("extract_aborted")) {
|
||||
return false;
|
||||
@ -1190,11 +1206,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
if (!shouldFallbackToExternalZip(error)) {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => {
|
||||
archivePercent = Math.max(archivePercent, value);
|
||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||
}, options.signal);
|
||||
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
|
||||
} catch (externalError) {
|
||||
if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) {
|
||||
throw error;
|
||||
}
|
||||
throw externalError;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -34,7 +34,8 @@ function validateStringArray(value: unknown, name: string): string[] {
|
||||
/* ── Single Instance Lock ───────────────────────────────────────── */
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
app.exit(0);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/* ── Unhandled error protection ─────────────────────────────────── */
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
||||
import { compactErrorText, sleep } from "./utils";
|
||||
|
||||
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.29";
|
||||
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.30";
|
||||
|
||||
export interface UnrestrictedLink {
|
||||
fileName: string;
|
||||
|
||||
@ -59,9 +59,6 @@ function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] {
|
||||
|
||||
function normalizeAbsoluteDir(value: unknown, fallback: string): string {
|
||||
const text = asText(value);
|
||||
if (/^\/[\s\S]+/.test(text)) {
|
||||
return text.replace(/\\/g, "/");
|
||||
}
|
||||
if (!text || !path.isAbsolute(text)) {
|
||||
return path.resolve(fallback);
|
||||
}
|
||||
|
||||
@ -116,11 +116,42 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function sameStringArray(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < a.length; index += 1) {
|
||||
if (a[index] !== b[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatMbpsInputFromKbps(kbps: number): string {
|
||||
const mbps = Math.max(0, Number(kbps) || 0) / 1024;
|
||||
return String(Number(mbps.toFixed(2)));
|
||||
}
|
||||
|
||||
function parseMbpsInput(value: string): number | null {
|
||||
const normalized = String(value || "").trim().replace(/,/g, ".");
|
||||
if (!normalized) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function App(): ReactElement {
|
||||
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
||||
const [tab, setTab] = useState<Tab>("collector");
|
||||
const [statusToast, setStatusToast] = useState("");
|
||||
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
|
||||
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
|
||||
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
|
||||
const [settingsDirty, setSettingsDirty] = useState(false);
|
||||
const settingsDirtyRef = useRef(false);
|
||||
const settingsDraftRevisionRef = useRef(0);
|
||||
@ -138,6 +169,9 @@ export function App(): ReactElement {
|
||||
const activeCollectorTabRef = useRef(activeCollectorTab);
|
||||
const activeTabRef = useRef<Tab>(tab);
|
||||
const packageOrderRef = useRef<string[]>([]);
|
||||
const serverPackageOrderRef = useRef<string[]>([]);
|
||||
const pendingPackageOrderRef = useRef<string[] | null>(null);
|
||||
const pendingPackageOrderAtRef = useRef(0);
|
||||
const draggedPackageIdRef = useRef<string | null>(null);
|
||||
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||
const [downloadSearch, setDownloadSearch] = useState("");
|
||||
@ -153,6 +187,8 @@ export function App(): ReactElement {
|
||||
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
||||
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
||||
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
|
||||
const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
|
||||
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
||||
|
||||
@ -169,9 +205,37 @@ export function App(): ReactElement {
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
packageOrderRef.current = snapshot.session.packageOrder;
|
||||
const incoming = snapshot.session.packageOrder;
|
||||
serverPackageOrderRef.current = incoming;
|
||||
|
||||
const pending = pendingPackageOrderRef.current;
|
||||
if (!pending) {
|
||||
packageOrderRef.current = incoming;
|
||||
return;
|
||||
}
|
||||
|
||||
if (sameStringArray(pending, incoming)) {
|
||||
pendingPackageOrderRef.current = null;
|
||||
pendingPackageOrderAtRef.current = 0;
|
||||
packageOrderRef.current = incoming;
|
||||
return;
|
||||
}
|
||||
|
||||
const maxOptimisticHoldMs = 1500;
|
||||
if (Date.now() - pendingPackageOrderAtRef.current >= maxOptimisticHoldMs) {
|
||||
pendingPackageOrderRef.current = null;
|
||||
pendingPackageOrderAtRef.current = 0;
|
||||
packageOrderRef.current = incoming;
|
||||
return;
|
||||
}
|
||||
|
||||
packageOrderRef.current = pending;
|
||||
}, [snapshot.session.packageOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
|
||||
}, [settingsDraft.speedLimitKbps]);
|
||||
|
||||
const showToast = useCallback((message: string, timeoutMs = 2200): void => {
|
||||
setStatusToast(message);
|
||||
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
||||
@ -181,6 +245,15 @@ export function App(): ReactElement {
|
||||
}, timeoutMs);
|
||||
}, []);
|
||||
|
||||
const clearImportQueueFocusListener = useCallback((): void => {
|
||||
const handler = importQueueFocusHandlerRef.current;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
window.removeEventListener("focus", handler);
|
||||
importQueueFocusHandlerRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let unsubClipboard: (() => void) | null = null;
|
||||
@ -243,6 +316,7 @@ export function App(): ReactElement {
|
||||
if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
|
||||
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
||||
if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); }
|
||||
clearImportQueueFocusListener();
|
||||
if (startConflictResolverRef.current) {
|
||||
const resolver = startConflictResolverRef.current;
|
||||
startConflictResolverRef.current = null;
|
||||
@ -253,10 +327,14 @@ export function App(): ReactElement {
|
||||
confirmResolverRef.current = null;
|
||||
resolver(false);
|
||||
}
|
||||
while (confirmQueueRef.current.length > 0) {
|
||||
const request = confirmQueueRef.current.shift();
|
||||
request?.resolve(false);
|
||||
}
|
||||
if (unsubscribe) { unsubscribe(); }
|
||||
if (unsubClipboard) { unsubClipboard(); }
|
||||
};
|
||||
}, []);
|
||||
}, [clearImportQueueFocusListener]);
|
||||
|
||||
const downloadsTabActive = tab === "downloads";
|
||||
const deferredDownloadSearch = useDeferredValue(downloadSearch);
|
||||
@ -449,6 +527,9 @@ export function App(): ReactElement {
|
||||
message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`,
|
||||
confirmLabel: "Jetzt installieren"
|
||||
});
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; }
|
||||
const install = await window.rd.installUpdate();
|
||||
if (!mountedRef.current) {
|
||||
@ -507,21 +588,34 @@ export function App(): ReactElement {
|
||||
});
|
||||
};
|
||||
|
||||
const closeConfirmPrompt = (confirmed: boolean): void => {
|
||||
const pumpConfirmQueue = useCallback((): void => {
|
||||
if (confirmResolverRef.current) {
|
||||
return;
|
||||
}
|
||||
const next = confirmQueueRef.current.shift();
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
confirmResolverRef.current = next.resolve;
|
||||
setConfirmPrompt(next.prompt);
|
||||
}, []);
|
||||
|
||||
const closeConfirmPrompt = useCallback((confirmed: boolean): void => {
|
||||
const resolver = confirmResolverRef.current;
|
||||
confirmResolverRef.current = null;
|
||||
setConfirmPrompt(null);
|
||||
if (resolver) {
|
||||
resolver(confirmed);
|
||||
}
|
||||
};
|
||||
pumpConfirmQueue();
|
||||
}, [pumpConfirmQueue]);
|
||||
|
||||
const askConfirmPrompt = (prompt: ConfirmPromptState): Promise<boolean> => {
|
||||
const askConfirmPrompt = useCallback((prompt: ConfirmPromptState): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
confirmResolverRef.current = resolve;
|
||||
setConfirmPrompt(prompt);
|
||||
confirmQueueRef.current.push({ prompt, resolve });
|
||||
pumpConfirmQueue();
|
||||
});
|
||||
};
|
||||
}, [pumpConfirmQueue]);
|
||||
|
||||
const onStartDownloads = async (): Promise<void> => {
|
||||
await performQuickAction(async () => {
|
||||
@ -665,14 +759,14 @@ export function App(): ReactElement {
|
||||
};
|
||||
|
||||
const onWindowFocus = (): void => {
|
||||
window.removeEventListener("focus", onWindowFocus);
|
||||
clearImportQueueFocusListener();
|
||||
if (!input.files || input.files.length === 0) {
|
||||
releasePickerBusy();
|
||||
}
|
||||
};
|
||||
|
||||
input.onchange = async () => {
|
||||
window.removeEventListener("focus", onWindowFocus);
|
||||
clearImportQueueFocusListener();
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
releasePickerBusy();
|
||||
@ -688,6 +782,8 @@ export function App(): ReactElement {
|
||||
});
|
||||
};
|
||||
|
||||
clearImportQueueFocusListener();
|
||||
importQueueFocusHandlerRef.current = onWindowFocus;
|
||||
window.addEventListener("focus", onWindowFocus, { once: true });
|
||||
input.click();
|
||||
};
|
||||
@ -760,8 +856,13 @@ export function App(): ReactElement {
|
||||
if (target < 0 || target >= order.length) { return; }
|
||||
[order[idx], order[target]] = [order[target], order[idx]];
|
||||
setDownloadsSortDescending(false);
|
||||
packageOrderRef.current = order;
|
||||
pendingPackageOrderRef.current = [...order];
|
||||
pendingPackageOrderAtRef.current = Date.now();
|
||||
packageOrderRef.current = [...order];
|
||||
void window.rd.reorderPackages(order).catch((error) => {
|
||||
pendingPackageOrderRef.current = null;
|
||||
pendingPackageOrderAtRef.current = 0;
|
||||
packageOrderRef.current = serverPackageOrderRef.current;
|
||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
||||
});
|
||||
}, [showToast]);
|
||||
@ -775,8 +876,13 @@ export function App(): ReactElement {
|
||||
return;
|
||||
}
|
||||
setDownloadsSortDescending(false);
|
||||
packageOrderRef.current = nextOrder;
|
||||
pendingPackageOrderRef.current = [...nextOrder];
|
||||
pendingPackageOrderAtRef.current = Date.now();
|
||||
packageOrderRef.current = [...nextOrder];
|
||||
void window.rd.reorderPackages(nextOrder).catch((error) => {
|
||||
pendingPackageOrderRef.current = null;
|
||||
pendingPackageOrderAtRef.current = 0;
|
||||
packageOrderRef.current = serverPackageOrderRef.current;
|
||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
||||
});
|
||||
}, [showToast]);
|
||||
@ -874,6 +980,33 @@ export function App(): ReactElement {
|
||||
}, [showToast]);
|
||||
|
||||
const schedules = settingsDraft.bandwidthSchedules ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
setScheduleSpeedInputs((prev) => {
|
||||
const syncFromSettings = !settingsDirtyRef.current;
|
||||
let changed = false;
|
||||
const next: Record<string, string> = {};
|
||||
for (let index = 0; index < schedules.length; index += 1) {
|
||||
const schedule = schedules[index];
|
||||
const key = schedule.id || `schedule-${index}`;
|
||||
const normalized = formatMbpsInputFromKbps(schedule.speedLimitKbps);
|
||||
if (syncFromSettings || !Object.prototype.hasOwnProperty.call(prev, key)) {
|
||||
next[key] = normalized;
|
||||
if (prev[key] !== normalized) {
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
next[key] = prev[key];
|
||||
}
|
||||
}
|
||||
const prevKeys = Object.keys(prev);
|
||||
if (prevKeys.length !== Object.keys(next).length) {
|
||||
changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [schedules, settingsDirty]);
|
||||
|
||||
const addSchedule = (): void => {
|
||||
settingsDraftRevisionRef.current += 1;
|
||||
settingsDirtyRef.current = true;
|
||||
@ -1066,7 +1199,10 @@ export function App(): ReactElement {
|
||||
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
|
||||
const sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
|
||||
packageOrderRef.current = sorted;
|
||||
void window.rd.reorderPackages(sorted);
|
||||
void window.rd.reorderPackages(sorted).catch((error) => {
|
||||
packageOrderRef.current = serverPackageOrderRef.current;
|
||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{downloadsSortDescending ? "Z-A" : "A-Z"}
|
||||
@ -1221,8 +1357,17 @@ export function App(): ReactElement {
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={Number((settingsDraft.speedLimitKbps / 1024).toFixed(2))}
|
||||
onChange={(e) => setSpeedLimitMbps(Number(e.target.value) || 0)}
|
||||
value={speedLimitInput}
|
||||
onChange={(event) => setSpeedLimitInput(event.target.value)}
|
||||
onBlur={(event) => {
|
||||
const parsed = parseMbpsInput(event.target.value);
|
||||
if (parsed === null) {
|
||||
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
|
||||
return;
|
||||
}
|
||||
setSpeedLimitMbps(parsed);
|
||||
setSpeedLimitInput(formatMbpsInputFromKbps(Math.floor(parsed * 1024)));
|
||||
}}
|
||||
disabled={!settingsDraft.speedLimitEnabled}
|
||||
/>
|
||||
</div>
|
||||
@ -1243,8 +1388,11 @@ export function App(): ReactElement {
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
|
||||
<h4>Bandbreitenplanung</h4>
|
||||
{schedules.map((s, i) => (
|
||||
<div key={s.id || `schedule-${i}`} className="schedule-row">
|
||||
{schedules.map((s, i) => {
|
||||
const scheduleKey = s.id || `schedule-${i}`;
|
||||
const speedInput = scheduleSpeedInputs[scheduleKey] ?? formatMbpsInputFromKbps(s.speedLimitKbps);
|
||||
return (
|
||||
<div key={scheduleKey} className="schedule-row">
|
||||
<input type="number" min={0} max={23} value={s.startHour} onChange={(e) => updateSchedule(i, "startHour", Number(e.target.value))} title="Von (Stunde)" />
|
||||
<span>-</span>
|
||||
<input type="number" min={0} max={23} value={s.endHour} onChange={(e) => updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" />
|
||||
@ -1253,15 +1401,29 @@ export function App(): ReactElement {
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={Number((s.speedLimitKbps / 1024).toFixed(2))}
|
||||
onChange={(e) => updateSchedule(i, "speedLimitKbps", Math.floor((Number(e.target.value) || 0) * 1024))}
|
||||
value={speedInput}
|
||||
onChange={(event) => {
|
||||
const nextText = event.target.value;
|
||||
setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: nextText }));
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const parsed = parseMbpsInput(event.target.value);
|
||||
if (parsed === null) {
|
||||
setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: formatMbpsInputFromKbps(s.speedLimitKbps) }));
|
||||
return;
|
||||
}
|
||||
const nextKbps = Math.floor(parsed * 1024);
|
||||
setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: formatMbpsInputFromKbps(nextKbps) }));
|
||||
updateSchedule(i, "speedLimitKbps", nextKbps);
|
||||
}}
|
||||
title="MB/s (0=unbegrenzt)"
|
||||
/>
|
||||
<span>MB/s</span>
|
||||
<input type="checkbox" checked={s.enabled} onChange={(e) => updateSchedule(i, "enabled", e.target.checked)} />
|
||||
<button className="btn danger" onClick={() => removeSchedule(i)}>X</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<button className="btn" onClick={addSchedule}>Zeitregel hinzufügen</button>
|
||||
</article>
|
||||
|
||||
|
||||
@ -555,7 +555,7 @@ describe("extractor", () => {
|
||||
expect(targets.has(r02)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fallback to external extractor when ZIP safety guard triggers", async () => {
|
||||
it("keeps original ZIP size guard error when external fallback is unavailable", async () => {
|
||||
const previousLimit = process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
|
||||
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = "8";
|
||||
|
||||
|
||||
@ -409,7 +409,7 @@ describe("settings storage", () => {
|
||||
|
||||
// Old fields should be preserved
|
||||
expect(loaded.token).toBe("my-token");
|
||||
expect(loaded.outputDir).toBe("/custom/output");
|
||||
expect(loaded.outputDir).toBe(path.resolve("/custom/output"));
|
||||
|
||||
// Missing new fields should get default values
|
||||
expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user