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",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.29",
|
"version": "1.4.30",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.29",
|
"version": "1.4.30",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.29",
|
"version": "1.4.30",
|
||||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
|||||||
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
|
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
|
||||||
|
|
||||||
const API_TIMEOUT_MS = 30000;
|
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 RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024;
|
||||||
|
|
||||||
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
|
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");
|
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 {
|
function isUnsupportedExtractorSwitchError(errorText: string): boolean {
|
||||||
const text = String(errorText || "").toLowerCase();
|
const text = String(errorText || "").toLowerCase();
|
||||||
return text.includes("unknown switch")
|
return text.includes("unknown switch")
|
||||||
@ -705,17 +712,26 @@ async function runExternalExtract(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isZipSafetyGuardError(error: unknown): boolean {
|
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();
|
const text = String(error || "").toLowerCase();
|
||||||
return text.includes("zip-eintrag zu groß")
|
return text.includes("zip-eintrag zu groß")
|
||||||
|| text.includes("zip-eintrag komprimiert zu groß")
|
|| text.includes("zip-eintrag komprimiert zu groß")
|
||||||
|| text.includes("zip-eintrag ohne sichere groessenangabe")
|
|| text.includes("zip-eintrag ohne sichere groessenangabe");
|
||||||
|| text.includes("path traversal");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldFallbackToExternalZip(error: unknown): boolean {
|
function shouldFallbackToExternalZip(error: unknown): boolean {
|
||||||
if (isZipSafetyGuardError(error)) {
|
if (isZipSafetyGuardError(error)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (isZipInternalLimitError(error)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const text = String(error || "").toLowerCase();
|
const text = String(error || "").toLowerCase();
|
||||||
if (text.includes("aborted:extract") || text.includes("extract_aborted")) {
|
if (text.includes("aborted:extract") || text.includes("extract_aborted")) {
|
||||||
return false;
|
return false;
|
||||||
@ -1190,11 +1206,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
if (!shouldFallbackToExternalZip(error)) {
|
if (!shouldFallbackToExternalZip(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => {
|
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => {
|
||||||
archivePercent = Math.max(archivePercent, value);
|
archivePercent = Math.max(archivePercent, value);
|
||||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||||
}, options.signal);
|
}, options.signal);
|
||||||
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
|
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
|
||||||
|
} catch (externalError) {
|
||||||
|
if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw externalError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -34,7 +34,8 @@ function validateStringArray(value: unknown, name: string): string[] {
|
|||||||
/* ── Single Instance Lock ───────────────────────────────────────── */
|
/* ── Single Instance Lock ───────────────────────────────────────── */
|
||||||
const gotLock = app.requestSingleInstanceLock();
|
const gotLock = app.requestSingleInstanceLock();
|
||||||
if (!gotLock) {
|
if (!gotLock) {
|
||||||
app.quit();
|
app.exit(0);
|
||||||
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Unhandled error protection ─────────────────────────────────── */
|
/* ── Unhandled error protection ─────────────────────────────────── */
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
||||||
import { compactErrorText, sleep } from "./utils";
|
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 {
|
export interface UnrestrictedLink {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
|||||||
@ -59,9 +59,6 @@ function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] {
|
|||||||
|
|
||||||
function normalizeAbsoluteDir(value: unknown, fallback: string): string {
|
function normalizeAbsoluteDir(value: unknown, fallback: string): string {
|
||||||
const text = asText(value);
|
const text = asText(value);
|
||||||
if (/^\/[\s\S]+/.test(text)) {
|
|
||||||
return text.replace(/\\/g, "/");
|
|
||||||
}
|
|
||||||
if (!text || !path.isAbsolute(text)) {
|
if (!text || !path.isAbsolute(text)) {
|
||||||
return path.resolve(fallback);
|
return path.resolve(fallback);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,11 +116,42 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
|
|||||||
return sorted;
|
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 {
|
export function App(): ReactElement {
|
||||||
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
||||||
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 [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
|
||||||
|
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
|
||||||
const [settingsDirty, setSettingsDirty] = useState(false);
|
const [settingsDirty, setSettingsDirty] = useState(false);
|
||||||
const settingsDirtyRef = useRef(false);
|
const settingsDirtyRef = useRef(false);
|
||||||
const settingsDraftRevisionRef = useRef(0);
|
const settingsDraftRevisionRef = useRef(0);
|
||||||
@ -138,6 +169,9 @@ export function App(): ReactElement {
|
|||||||
const activeCollectorTabRef = useRef(activeCollectorTab);
|
const activeCollectorTabRef = useRef(activeCollectorTab);
|
||||||
const activeTabRef = useRef<Tab>(tab);
|
const activeTabRef = useRef<Tab>(tab);
|
||||||
const packageOrderRef = useRef<string[]>([]);
|
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 draggedPackageIdRef = useRef<string | null>(null);
|
||||||
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||||
const [downloadSearch, setDownloadSearch] = useState("");
|
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 startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
||||||
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
||||||
const confirmResolverRef = useRef<((confirmed: boolean) => void) | 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];
|
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
||||||
|
|
||||||
@ -169,9 +205,37 @@ export function App(): ReactElement {
|
|||||||
}, [tab]);
|
}, [tab]);
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [snapshot.session.packageOrder]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
|
||||||
|
}, [settingsDraft.speedLimitKbps]);
|
||||||
|
|
||||||
const showToast = useCallback((message: string, timeoutMs = 2200): void => {
|
const showToast = useCallback((message: string, timeoutMs = 2200): void => {
|
||||||
setStatusToast(message);
|
setStatusToast(message);
|
||||||
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
||||||
@ -181,6 +245,15 @@ export function App(): ReactElement {
|
|||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearImportQueueFocusListener = useCallback((): void => {
|
||||||
|
const handler = importQueueFocusHandlerRef.current;
|
||||||
|
if (!handler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.removeEventListener("focus", handler);
|
||||||
|
importQueueFocusHandlerRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
let unsubClipboard: (() => void) | null = null;
|
let unsubClipboard: (() => void) | null = null;
|
||||||
@ -243,6 +316,7 @@ export function App(): ReactElement {
|
|||||||
if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
|
if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
|
||||||
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
||||||
if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); }
|
if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); }
|
||||||
|
clearImportQueueFocusListener();
|
||||||
if (startConflictResolverRef.current) {
|
if (startConflictResolverRef.current) {
|
||||||
const resolver = startConflictResolverRef.current;
|
const resolver = startConflictResolverRef.current;
|
||||||
startConflictResolverRef.current = null;
|
startConflictResolverRef.current = null;
|
||||||
@ -253,10 +327,14 @@ export function App(): ReactElement {
|
|||||||
confirmResolverRef.current = null;
|
confirmResolverRef.current = null;
|
||||||
resolver(false);
|
resolver(false);
|
||||||
}
|
}
|
||||||
|
while (confirmQueueRef.current.length > 0) {
|
||||||
|
const request = confirmQueueRef.current.shift();
|
||||||
|
request?.resolve(false);
|
||||||
|
}
|
||||||
if (unsubscribe) { unsubscribe(); }
|
if (unsubscribe) { unsubscribe(); }
|
||||||
if (unsubClipboard) { unsubClipboard(); }
|
if (unsubClipboard) { unsubClipboard(); }
|
||||||
};
|
};
|
||||||
}, []);
|
}, [clearImportQueueFocusListener]);
|
||||||
|
|
||||||
const downloadsTabActive = tab === "downloads";
|
const downloadsTabActive = tab === "downloads";
|
||||||
const deferredDownloadSearch = useDeferredValue(downloadSearch);
|
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?`,
|
message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`,
|
||||||
confirmLabel: "Jetzt installieren"
|
confirmLabel: "Jetzt installieren"
|
||||||
});
|
});
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; }
|
if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; }
|
||||||
const install = await window.rd.installUpdate();
|
const install = await window.rd.installUpdate();
|
||||||
if (!mountedRef.current) {
|
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;
|
const resolver = confirmResolverRef.current;
|
||||||
confirmResolverRef.current = null;
|
confirmResolverRef.current = null;
|
||||||
setConfirmPrompt(null);
|
setConfirmPrompt(null);
|
||||||
if (resolver) {
|
if (resolver) {
|
||||||
resolver(confirmed);
|
resolver(confirmed);
|
||||||
}
|
}
|
||||||
};
|
pumpConfirmQueue();
|
||||||
|
}, [pumpConfirmQueue]);
|
||||||
|
|
||||||
const askConfirmPrompt = (prompt: ConfirmPromptState): Promise<boolean> => {
|
const askConfirmPrompt = useCallback((prompt: ConfirmPromptState): Promise<boolean> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
confirmResolverRef.current = resolve;
|
confirmQueueRef.current.push({ prompt, resolve });
|
||||||
setConfirmPrompt(prompt);
|
pumpConfirmQueue();
|
||||||
});
|
});
|
||||||
};
|
}, [pumpConfirmQueue]);
|
||||||
|
|
||||||
const onStartDownloads = async (): Promise<void> => {
|
const onStartDownloads = async (): Promise<void> => {
|
||||||
await performQuickAction(async () => {
|
await performQuickAction(async () => {
|
||||||
@ -665,14 +759,14 @@ export function App(): ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onWindowFocus = (): void => {
|
const onWindowFocus = (): void => {
|
||||||
window.removeEventListener("focus", onWindowFocus);
|
clearImportQueueFocusListener();
|
||||||
if (!input.files || input.files.length === 0) {
|
if (!input.files || input.files.length === 0) {
|
||||||
releasePickerBusy();
|
releasePickerBusy();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
window.removeEventListener("focus", onWindowFocus);
|
clearImportQueueFocusListener();
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
releasePickerBusy();
|
releasePickerBusy();
|
||||||
@ -688,6 +782,8 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
clearImportQueueFocusListener();
|
||||||
|
importQueueFocusHandlerRef.current = onWindowFocus;
|
||||||
window.addEventListener("focus", onWindowFocus, { once: true });
|
window.addEventListener("focus", onWindowFocus, { once: true });
|
||||||
input.click();
|
input.click();
|
||||||
};
|
};
|
||||||
@ -760,8 +856,13 @@ export function App(): ReactElement {
|
|||||||
if (target < 0 || target >= order.length) { return; }
|
if (target < 0 || target >= order.length) { return; }
|
||||||
[order[idx], order[target]] = [order[target], order[idx]];
|
[order[idx], order[target]] = [order[target], order[idx]];
|
||||||
setDownloadsSortDescending(false);
|
setDownloadsSortDescending(false);
|
||||||
packageOrderRef.current = order;
|
pendingPackageOrderRef.current = [...order];
|
||||||
|
pendingPackageOrderAtRef.current = Date.now();
|
||||||
|
packageOrderRef.current = [...order];
|
||||||
void window.rd.reorderPackages(order).catch((error) => {
|
void window.rd.reorderPackages(order).catch((error) => {
|
||||||
|
pendingPackageOrderRef.current = null;
|
||||||
|
pendingPackageOrderAtRef.current = 0;
|
||||||
|
packageOrderRef.current = serverPackageOrderRef.current;
|
||||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
});
|
});
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
@ -775,8 +876,13 @@ export function App(): ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDownloadsSortDescending(false);
|
setDownloadsSortDescending(false);
|
||||||
packageOrderRef.current = nextOrder;
|
pendingPackageOrderRef.current = [...nextOrder];
|
||||||
|
pendingPackageOrderAtRef.current = Date.now();
|
||||||
|
packageOrderRef.current = [...nextOrder];
|
||||||
void window.rd.reorderPackages(nextOrder).catch((error) => {
|
void window.rd.reorderPackages(nextOrder).catch((error) => {
|
||||||
|
pendingPackageOrderRef.current = null;
|
||||||
|
pendingPackageOrderAtRef.current = 0;
|
||||||
|
packageOrderRef.current = serverPackageOrderRef.current;
|
||||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
});
|
});
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
@ -874,6 +980,33 @@ export function App(): ReactElement {
|
|||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
const schedules = settingsDraft.bandwidthSchedules ?? [];
|
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 => {
|
const addSchedule = (): void => {
|
||||||
settingsDraftRevisionRef.current += 1;
|
settingsDraftRevisionRef.current += 1;
|
||||||
settingsDirtyRef.current = true;
|
settingsDirtyRef.current = true;
|
||||||
@ -1066,7 +1199,10 @@ export function App(): ReactElement {
|
|||||||
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
|
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
|
||||||
const sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
|
const sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
|
||||||
packageOrderRef.current = sorted;
|
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"}
|
{downloadsSortDescending ? "Z-A" : "A-Z"}
|
||||||
@ -1221,8 +1357,17 @@ export function App(): ReactElement {
|
|||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
value={Number((settingsDraft.speedLimitKbps / 1024).toFixed(2))}
|
value={speedLimitInput}
|
||||||
onChange={(e) => setSpeedLimitMbps(Number(e.target.value) || 0)}
|
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}
|
disabled={!settingsDraft.speedLimitEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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.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>
|
<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>
|
<h4>Bandbreitenplanung</h4>
|
||||||
{schedules.map((s, i) => (
|
{schedules.map((s, i) => {
|
||||||
<div key={s.id || `schedule-${i}`} className="schedule-row">
|
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)" />
|
<input type="number" min={0} max={23} value={s.startHour} onChange={(e) => updateSchedule(i, "startHour", Number(e.target.value))} title="Von (Stunde)" />
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<input type="number" min={0} max={23} value={s.endHour} onChange={(e) => updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" />
|
<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"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
value={Number((s.speedLimitKbps / 1024).toFixed(2))}
|
value={speedInput}
|
||||||
onChange={(e) => updateSchedule(i, "speedLimitKbps", Math.floor((Number(e.target.value) || 0) * 1024))}
|
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)"
|
title="MB/s (0=unbegrenzt)"
|
||||||
/>
|
/>
|
||||||
<span>MB/s</span>
|
<span>MB/s</span>
|
||||||
<input type="checkbox" checked={s.enabled} onChange={(e) => updateSchedule(i, "enabled", e.target.checked)} />
|
<input type="checkbox" checked={s.enabled} onChange={(e) => updateSchedule(i, "enabled", e.target.checked)} />
|
||||||
<button className="btn danger" onClick={() => removeSchedule(i)}>X</button>
|
<button className="btn danger" onClick={() => removeSchedule(i)}>X</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<button className="btn" onClick={addSchedule}>Zeitregel hinzufügen</button>
|
<button className="btn" onClick={addSchedule}>Zeitregel hinzufügen</button>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@ -555,7 +555,7 @@ describe("extractor", () => {
|
|||||||
expect(targets.has(r02)).toBe(true);
|
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;
|
const previousLimit = process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
|
||||||
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = "8";
|
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = "8";
|
||||||
|
|
||||||
|
|||||||
@ -409,7 +409,7 @@ describe("settings storage", () => {
|
|||||||
|
|
||||||
// Old fields should be preserved
|
// Old fields should be preserved
|
||||||
expect(loaded.token).toBe("my-token");
|
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
|
// Missing new fields should get default values
|
||||||
expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback);
|
expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user