From f865a1e41577a6514a3873873de07c912b6981e3 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Tue, 11 Aug 2026 17:31:22 +0200 Subject: [PATCH] release: ship v2.0.23 queue and account reliability fixes Keep active packages in activation order, remove status-driven package expansion, refresh parked Mega-Debrid work after account-pool changes, normalize RapidGator aliases, aggregate missed update notes, isolate new account credentials, and update regression coverage and release documentation. --- CHANGELOG.md | 30 ++ package-lock.json | 4 +- package.json | 2 +- src/main/debrid.ts | 15 +- src/main/download-manager.ts | 84 +++- src/main/update.ts | 162 ++++++-- src/renderer/App.tsx | 142 +++---- src/renderer/download-format.ts | 9 +- src/renderer/package-order.ts | 61 +-- src/renderer/shell/shell.css | 29 +- src/renderer/theme.css | 6 + .../views/downloads/DownloadsTable.tsx | 4 + src/renderer/views/downloads/downloads.css | 4 +- src/shared/hoster.ts | 24 ++ tests/debug-server.test.ts | 4 +- tests/download-manager.test.ts | 160 ++++++++ tests/downloads-view.test.tsx | 30 +- tests/package-order.test.ts | 46 ++- tests/settings-view.test.tsx | 19 + tests/statistics-view.test.tsx | 2 +- tests/update-experience.test.tsx | 360 +++++++++--------- tests/update.test.ts | 50 ++- 22 files changed, 852 insertions(+), 395 deletions(-) create mode 100644 src/shared/hoster.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef5128..dbaeda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [2.0.23] - 2026-08-11 + +### Update experience + +- Changed the available-update header action to a dedicated light-blue treatment with dark high-contrast text. +- Added cumulative release notes for every stable version newer than the installed application, ordered from newest to oldest. +- Excluded draft releases, prereleases, the installed version, and older versions from cumulative update notes. +- Added a bounded vertical scroll area so long multi-version changelogs remain usable without overflowing the update dialog. + +### Download queue + +- Unified RapidGator main and short-link domains under one host identity for icons, host counts, routing, limits, and cooldowns. +- Centered service and status values beneath their corresponding column headings. +- Removed archive filenames from visible password-cracking progress while retaining full technical details in the status tooltip. +- Kept active packages in their activation order and appended newly active packages behind downloads that were already running. +- Removed status-driven automatic package expansion so collapse state changes only through explicit user actions. + +### Account handling + +- Applied added, edited, disabled, and re-enabled Mega-Debrid accounts to the active scheduler without requiring an application restart. +- Released only Mega-Debrid reset-parked queue items when a usable account pool becomes available while preserving unrelated retry delays. +- Prevented new Mega-Debrid account forms from exposing stored credentials in an unrelated token field. +- Synchronized item and package status immediately when a provider retry is queued. + +### Reliability and testing + +- Kept the latest release notes as a fallback when the release history cannot be loaded. +- Added regression coverage for cumulative version filtering, ordering, update colors, changelog scrolling, RapidGator aliases, centered queue cells, compact password progress, live account-pool refresh, stable active ordering, and user-controlled package expansion. +- Verified the update dialog with twelve version sections at a 1120 by 760 pixel viewport. + ## [2.0.22] - 2026-08-11 ### History management diff --git a/package-lock.json b/package-lock.json index c47ce4f..a1f37dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "2.0.22", + "version": "2.0.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "2.0.22", + "version": "2.0.23", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 6eec620..bddb8b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "2.0.22", + "version": "2.0.23", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/debrid.ts b/src/main/debrid.ts index aa702ed..c20ae73 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1,5 +1,6 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts"; +import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts"; +import { extractHosterFromUrl } from "../shared/hoster"; import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types"; import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; @@ -557,17 +558,7 @@ const PROVIDER_LABELS: Record = { linksnappy: "LinkSnappy" }; -function extractHosterFromUrl(url: string): string { - try { - const host = new URL(url).hostname.replace(/^www\./, "").toLowerCase(); - const parts = host.split("."); - return parts.length >= 2 ? parts[parts.length - 2] : host; - } catch { - return ""; - } -} - -interface ProviderUnrestrictedLink extends UnrestrictedLink { +interface ProviderUnrestrictedLink extends UnrestrictedLink { provider: DebridProvider; providerLabel: string; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 3e43862..d27239a 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -21,7 +21,8 @@ import { StartConflictEntry, StartConflictResolutionResult, UiSnapshot, DebridAccountStatus } from "../shared/types"; -import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { extractHosterFromUrl } from "../shared/hoster"; import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; import { addDebridLinkApiKeyDailyUsageBytes, @@ -470,15 +471,9 @@ function isArchiveLikePath(filePath: string): boolean { return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|z\d{1,3}|7z(?:\.\d+)?)$/i.test(lower); } -function extractHosterKey(link: string): string { - try { - const host = new URL(link).hostname.replace(/^www\./, "").toLowerCase(); - const parts = host.split("."); - return parts.length >= 2 ? parts[parts.length - 2] : host; - } catch { - return ""; - } -} +function extractHosterKey(link: string): string { + return extractHosterFromUrl(link); +} function isLargeBinaryLikePath(filePath: string): boolean { const lower = path.basename(String(filePath || "")).toLowerCase(); @@ -2175,7 +2170,17 @@ export class DownloadManager extends EventEmitter { } public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void { - const previous = this.settings; + const previous = this.settings; + const previousMegaPool = getAvailableMegaDebridAccounts(previous) + .map((account) => `${account.id}:${account.password}`) + .sort() + .join("\n"); + const nextMegaAccounts = getAvailableMegaDebridAccounts(next); + const nextMegaPool = nextMegaAccounts + .map((account) => `${account.id}:${account.password}`) + .sort() + .join("\n"); + const megaPoolChanged = previousMegaPool !== nextMegaPool && nextMegaAccounts.length > 0; next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0); const now = nowMs(); @@ -2232,9 +2237,13 @@ export class DownloadManager extends EventEmitter { } } } - if (clearedProviderFailures > 0) { - logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`); - } + if (clearedProviderFailures > 0) { + logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`); + } + + if (!opts?.settingsOnlyImport && megaPoolChanged) { + this.releaseMegaDebridResetParks(); + } if (!opts?.settingsOnlyImport) { this.resolveExistingQueuedOpaqueFilenames(); @@ -2243,8 +2252,43 @@ export class DownloadManager extends EventEmitter { this.applyRetroactiveCleanupPolicy(); } } - this.emitState(); - } + this.emitState(); + } + + private releaseMegaDebridResetParks(): number { + const activeItemIds = new Set([...this.activeTasks.values()].map((task) => task.itemId)); + const affectedPackageIds = new Set(); + let released = 0; + for (const item of Object.values(this.session.items)) { + if (activeItemIds.has(item.id)) continue; + if (item.status !== "queued" && item.status !== "reconnect_wait") continue; + if (!/Mega-Debrid bis Tagesreset gesperrt/i.test(item.fullStatus || "")) continue; + this.retryAfterByItem.delete(item.id); + this.retryStateByItem.delete(item.id); + item.status = "queued"; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.provider = null; + item.providerLabel = undefined; + item.providerAccountId = undefined; + item.providerAccountLabel = undefined; + item.updatedAt = nowMs(); + affectedPackageIds.add(item.packageId); + released += 1; + } + for (const packageId of affectedPackageIds) { + const pkg = this.session.packages[packageId]; + if (pkg) this.refreshPackageStatus(pkg); + } + if (released > 0) { + logger.info(`Settings-Update: ${released} Mega-Debrid-Tagesreset-Warteitem(s) wegen geaendertem Account-Pool freigegeben`); + this.persistSoon(); + if (this.session.running) { + void this.ensureScheduler().catch((error) => logger.error(`Scheduler nach Account-Update fehlgeschlagen: ${compactErrorText(error)}`)); + } + } + return released; + } public getSettings(): AppSettings { return this.settings; @@ -8793,9 +8837,11 @@ export class DownloadManager extends EventEmitter { genericRetries: Number(active.genericErrorRetries || 0), freshRetryUsed: Boolean(active.freshRetryUsed), resumeHardResetUsed: Boolean(active.resumeHardResetUsed) - }); - this.retryAfterByItem.set(item.id, nowMs() + waitMs); - } + }); + this.retryAfterByItem.set(item.id, nowMs() + waitMs); + const pkg = this.session.packages[item.packageId]; + if (pkg) this.refreshPackageStatus(pkg); + } private scheduleHttp416Retry( item: DownloadItem, diff --git a/src/main/update.ts b/src/main/update.ts index 852c765..9ef4948 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -126,26 +126,36 @@ function combineSignals(primary: AbortSignal, secondary?: AbortSignal): AbortSig return AbortSignal.any([primary, secondary]); } -async function readJsonBody(response: Response, timeoutMs: number): Promise | null> { - let timer: NodeJS.Timeout | null = null; - const timeoutPromise = new Promise((_resolve, reject) => { +async function readJsonValue(response: Response, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | null = null; + const timeoutPromise = new Promise((_resolve, reject) => { timer = setTimeout(() => { void response.body?.cancel().catch(() => undefined); reject(new Error(`timeout:${timeoutMs}`)); }, timeoutMs); - }); - - try { - const data = await Promise.race([ - response.json().catch(() => null) as Promise, - timeoutPromise, - ]); - if (!data || typeof data !== "object" || Array.isArray(data)) return null; - return data as Record; - } finally { - if (timer) clearTimeout(timer); - } -} + }); + + try { + return await Promise.race([ + response.json().catch(() => null) as Promise, + timeoutPromise, + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function readJsonBody(response: Response, timeoutMs: number): Promise | null> { + const data = await readJsonValue(response, timeoutMs); + if (!data || typeof data !== "object" || Array.isArray(data)) return null; + return data as Record; +} + +async function readJsonArray(response: Response, timeoutMs: number): Promise> | null> { + const data = await readJsonValue(response, timeoutMs); + if (!Array.isArray(data)) return null; + return data.filter((entry): entry is Record => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)); +} async function readTextBody(response: Response, timeoutMs: number): Promise { let timer: NodeJS.Timeout | null = null; @@ -357,7 +367,7 @@ async function verifyDownloadedInstaller(filePath: string, digestRaw: string): P logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`); } -async function fetchRelease(repo: string, endpoint: string): Promise<{ +async function fetchRelease(repo: string, endpoint: string): Promise<{ ok: boolean; status: number; payload: Record | null; @@ -372,8 +382,26 @@ async function fetchRelease(repo: string, endpoint: string): Promise<{ return { ok: response.ok, status: response.status, payload }; } finally { tc.clear(); - } -} + } +} + +async function fetchReleasePage(repo: string, page: number): Promise<{ + ok: boolean; + status: number; + payload: Array> | null; +}> { + const tc = timeoutController(RELEASE_FETCH_TIMEOUT_MS); + try { + const response = await fetch(`${API_BASE}/repos/${repo}/releases?per_page=100&page=${page}`, { + headers: { Accept: "application/vnd.github+json", "User-Agent": USER_AGENT }, + signal: tc.signal, + }); + const payload = await readJsonArray(response, RELEASE_FETCH_TIMEOUT_MS); + return { ok: response.ok, status: response.status, payload }; + } finally { + tc.clear(); + } +} function readAssets(payload: Record): ReleaseAsset[] { const raw = Array.isArray(payload.assets) ? (payload.assets as Array>) : []; @@ -408,7 +436,7 @@ function isDraftOrPrerelease(payload: Record): boolean { return Boolean(payload.draft) || Boolean(payload.prerelease); } -function parseReleasePayload(payload: Record, fallbackUrl: string): UpdateCheckResult { +function parseReleasePayload(payload: Record, fallbackUrl: string): UpdateCheckResult { const latestTag = String(payload.tag_name || `v${APP_VERSION}`).trim(); const latestVersion = latestTag.replace(/^v/i, "") || APP_VERSION; const releaseUrl = String(payload.html_url || fallbackUrl); @@ -425,8 +453,73 @@ function parseReleasePayload(payload: Record, fallbackUrl: stri setupAssetName: setup?.name || "", setupAssetDigest: setup?.digest || "", releaseNotes: body || undefined, - }; -} + }; +} + +function combineReleaseNotes( + payloads: Array>, + currentVersion: string, + latestVersion: string, +): string { + const seen = new Set(); + const releases = payloads + .filter((payload) => !isDraftOrPrerelease(payload)) + .map((payload) => { + const tag = String(payload.tag_name || "").trim(); + return { + tag, + version: tag.replace(/^v/i, ""), + body: typeof payload.body === "string" ? payload.body.trim() : "", + }; + }) + .filter((release) => ( + release.tag + && release.body + && isRemoteNewer(currentVersion, release.version) + && !isRemoteNewer(latestVersion, release.version) + )) + .sort((first, second) => { + if (isRemoteNewer(first.version, second.version)) return 1; + if (isRemoteNewer(second.version, first.version)) return -1; + return 0; + }); + + return releases + .filter((release) => { + const key = release.version.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .map((release) => `${release.tag}\n${release.body}`) + .join("\n\n"); +} + +async function resolveMissingReleaseNotes( + repo: string, + latestPayload: Record, + latestVersion: string, +): Promise { + const fallbackBody = typeof latestPayload.body === "string" ? latestPayload.body.trim() : ""; + const payloads: Array> = [latestPayload]; + + try { + for (let page = 1; page <= 10; page += 1) { + const result = await fetchReleasePage(repo, page); + if (!result.ok || !result.payload) break; + payloads.push(...result.payload); + const reachedInstalledVersion = result.payload.some((payload) => { + const version = String(payload.tag_name || "").trim().replace(/^v/i, ""); + return version && !isRemoteNewer(APP_VERSION, version); + }); + if (result.payload.length < 100 || reachedInstalledVersion) break; + } + } catch { + return fallbackBody; + } + + return combineReleaseNotes(payloads, APP_VERSION, latestVersion) || fallbackBody; +} function uniqueStrings(values: string[]): string[] { const seen = new Set(); @@ -804,7 +897,7 @@ export function buildInstallerLaunchArgs(): string[] { return ["/S", "--updated", "--force-run"]; } -export async function checkGitHubUpdate(repo: string): Promise { +export async function checkGitHubUpdate(repo: string): Promise { const safeRepo = normalizeUpdateRepo(repo); const fallbackUrl = `${WEB_BASE}/${safeRepo}/releases/latest`; const fallback: UpdateCheckResult = { @@ -817,15 +910,20 @@ export async function checkGitHubUpdate(repo: string): Promise = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] }; if (!kind) { return { @@ -815,7 +813,7 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password })); const loadedIds = new Set(parsed.map((a) => a.id)); const megaDisabledIds = (settings.megaDebridDisabledAccountIds || []).filter((id) => loadedIds.has(id)); - return { mode, kind, service, token: megaToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds }; + return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds }; } case "bestdebrid-api": return { mode, kind, service, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; @@ -846,9 +844,36 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n default: return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; } -} - -export function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings { +} + +export function buildAccountAddFields(dialog: AccountDialogState | null): AccountDialogField[] { + if (!dialog?.kind) { + return []; + } + const option = findAccountOption(dialog.kind); + return [ + ...((dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web") ? [ + { id: "megaNewLogin", label: "Login / E-Mail", type: "text" as const, value: dialog.megaNewLogin }, + { id: "megaNewPassword", label: "Passwort", type: "password" as const, value: dialog.megaNewPassword } + ] : option.needsCredentials ? [ + { id: "login", label: "Login / E-Mail", type: "text" as const, value: dialog.login }, + { id: "password", label: "Passwort", type: "password" as const, value: dialog.password } + ] : []), + ...(option.needsToken ? [ + { id: "token", label: dialog.kind === "debridlink-api" ? "API-Key" : "Token / API-Key", type: "password" as const, value: dialog.token } + ] : []), + { + id: "dailyLimitGb", + label: "Tageslimit (GB, optional)", + type: "number" as const, + value: dialog.dailyLimitGb, + placeholder: "Kein Limit", + help: "Der Zähler wird täglich um 00:00 Uhr zurückgesetzt." + } + ]; +} + +export function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings { if (!dialog.kind) { return settings; } @@ -1753,8 +1778,6 @@ export function App(): ReactElement { const snapshotRef = useRef(snapshot); snapshotRef.current = snapshot; const tabRef = useRef(tab); - const autoExpandedPkgsRef = useRef(new Set()); - const manualCollapsedPkgsRef = useRef(new Set()); tabRef.current = tab; const stateFlushTimerRef = useRef | null>(null); const toastTimerRef = useRef | null>(null); @@ -2270,12 +2293,7 @@ export function App(): ReactElement { changed = true; } } - for (const packageId of Array.from(manualCollapsedPkgsRef.current)) { - if (!snapshot.session.packages[packageId]) { - manualCollapsedPkgsRef.current.delete(packageId); - } - } - return changed ? next : prev; + return changed ? next : prev; }); }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]); @@ -2287,18 +2305,18 @@ export function App(): ReactElement { }, [snapshot.session.packages, snapshot.session.items]); const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1) - ? snapshot.session.items - : null; + ? snapshot.session.items + : null; const visiblePackages = useMemo(() => { if (!sortRelevantItems) { return packages; } return sortPackagesForDisplay( - packages, - sortRelevantItems, - true, - true - ); + packages, + sortRelevantItems, + true, + true + ); }, [packages, sortRelevantItems]); const downloadsViewCore = useMemo(() => buildDownloadsViewModel({ @@ -2338,36 +2356,6 @@ export function App(): ReactElement { void loadAllDebridHostInfo(true); }, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]); - useEffect(() => { - const extractingPkgIds: string[] = []; - const currentlyExtracting = new Set(); - for (const pkg of packages) { - const items = (pkg.itemIds ?? []).map((id) => snapshot.session.items[id]).filter(Boolean); - const isExtracting = items.some((item) => item.fullStatus?.startsWith("Entpacken -") && !item.fullStatus?.includes("Done")); - if (isExtracting) { - currentlyExtracting.add(pkg.id); - if (collapsedPackages[pkg.id] - && !manualCollapsedPkgsRef.current.has(pkg.id) - && !autoExpandedPkgsRef.current.has(pkg.id)) { - extractingPkgIds.push(pkg.id); - autoExpandedPkgsRef.current.add(pkg.id); - } - } - } - for (const id of autoExpandedPkgsRef.current) { - if (!currentlyExtracting.has(id)) { - autoExpandedPkgsRef.current.delete(id); - } - } - if (extractingPkgIds.length > 0) { - setCollapsedPackages((prev) => { - const next = { ...prev }; - for (const id of extractingPkgIds) next[id] = false; - return next; - }); - } - }, [packages, snapshot.session.items, collapsedPackages]); - const allPackagesCollapsed = useMemo(() => ( packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id]) ), [packages, collapsedPackages]); @@ -4029,17 +4017,11 @@ export function App(): ReactElement { } }, [showToast]); - const onPackageToggleCollapse = useCallback((packageId: string): void => { - setCollapsedPackages((prev) => { - const nextCollapsed = !(prev[packageId] ?? false); - if (nextCollapsed) { - manualCollapsedPkgsRef.current.add(packageId); - } else { - manualCollapsedPkgsRef.current.delete(packageId); - autoExpandedPkgsRef.current.delete(packageId); - } - return { ...prev, [packageId]: nextCollapsed }; - }); + const onPackageToggleCollapse = useCallback((packageId: string): void => { + setCollapsedPackages((prev) => { + const nextCollapsed = !(prev[packageId] ?? false); + return { ...prev, [packageId]: nextCollapsed }; + }); }, []); const onPackageCancel = useCallback((packageId: string): void => { @@ -5014,11 +4996,6 @@ export function App(): ReactElement { const next = { ...current }; for (const entry of packages) { next[entry.id] = targetState; - if (targetState) manualCollapsedPkgsRef.current.add(entry.id); - else { - manualCollapsedPkgsRef.current.delete(entry.id); - autoExpandedPkgsRef.current.delete(entry.id); - } } return next; }); @@ -5480,26 +5457,7 @@ export function App(): ReactElement { multi: option.kind === "megadebrid-api" || option.kind === "megadebrid-web" || option.kind === "debridlink-api", icon: ACCOUNT_SERVICE_ICONS[option.service] })); - const accountAddFields: AccountDialogField[] = accountDialog && accountDialogOption ? [ - ...((accountDialog.kind === "megadebrid-api" || accountDialog.kind === "megadebrid-web") ? [ - { id: "megaNewLogin", label: "Login / E-Mail", type: "text" as const, value: accountDialog.megaNewLogin }, - { id: "megaNewPassword", label: "Passwort", type: "password" as const, value: accountDialog.megaNewPassword } - ] : accountDialogOption.needsCredentials ? [ - { id: "login", label: "Login / E-Mail", type: "text" as const, value: accountDialog.login }, - { id: "password", label: "Passwort", type: "password" as const, value: accountDialog.password } - ] : []), - ...(accountDialogOption.needsToken ? [ - { id: "token", label: accountDialog.kind === "debridlink-api" ? "API-Key" : "Token / API-Key", type: "password" as const, value: accountDialog.token } - ] : []), - { - id: "dailyLimitGb", - label: "Tageslimit (GB, optional)", - type: "number" as const, - value: accountDialog.dailyLimitGb, - placeholder: "Kein Limit", - help: "Der Zähler wird täglich um 00:00 Uhr zurückgesetzt." - } - ] : []; + const accountAddFields = buildAccountAddFields(accountDialog); const accountAddDialog = ( = { @@ -48,13 +49,7 @@ export function formatDateTime(timestamp: number): string { } export function extractHoster(url: string): string { - try { - const host = new URL(url).hostname.replace(/^www\./, ""); - const parts = host.split("."); - return parts.length >= 2 ? parts[parts.length - 2] : host; - } catch { - return ""; - } + return extractHosterFromUrl(url); } export function formatHosterLabel(hoster: string): { compact: string; title: string; iconSrc?: string } { diff --git a/src/renderer/package-order.ts b/src/renderer/package-order.ts index a38bb52..1b563e3 100644 --- a/src/renderer/package-order.ts +++ b/src/renderer/package-order.ts @@ -1,6 +1,13 @@ import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types"; -const ACTIVE_PACKAGE_STATUSES = new Set(["downloading", "validating", "integrity_check", "extracting"]); +const ACTIVE_PACKAGE_STATUSES = new Set(["downloading", "validating", "integrity_check", "extracting"]); + +function isPackageActive(pkg: PackageEntry, itemsById: Record): boolean { + return pkg.itemIds.some((id) => { + const item = itemsById[id]; + return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status); + }); +} export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] { const fromIndex = order.indexOf(draggedPackageId); @@ -26,36 +33,36 @@ export function sortPackageOrderByName(order: string[], packages: Record, - running: boolean, - autoSortPackagesByProgress: boolean -): PackageEntry[] { +export function sortPackagesForDisplay( + packages: PackageEntry[], + itemsById: Record, + running: boolean, + autoSortPackagesByProgress: boolean +): PackageEntry[] { if (!running || !autoSortPackagesByProgress || packages.length <= 1) { return packages; } - const active: PackageEntry[] = []; - const rest: PackageEntry[] = []; - - // Float packages that have an active item to the top, but keep BOTH groups in - // their original (queue) order. Earlier this sorted the active group by live - // completedRatio/downloadedBytes — which change on every progress tick (every - // 150-700ms), so active packages visibly reshuffled the whole time. A package - // entering/leaving the active bucket is a real, discrete event (start/finish); - // ranking *within* the bucket by live bytes was pure jitter nobody needs. - for (const pkg of packages) { - const hasActive = pkg.itemIds.some((id) => { - const item = itemsById[id]; - return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status); - }); - (hasActive ? active : rest).push(pkg); - } - - if (active.length === 0 || active.length === packages.length) { - return packages; - } + const active = packages + .map((pkg, index) => ({ pkg, index })) + .filter(({ pkg }) => isPackageActive(pkg, itemsById)) + .sort((left, right) => { + const leftStartedAt = left.pkg.downloadStartedAt || 0; + const rightStartedAt = right.pkg.downloadStartedAt || 0; + if (leftStartedAt > 0 && rightStartedAt > 0 && leftStartedAt !== rightStartedAt) { + return leftStartedAt - rightStartedAt; + } + if (leftStartedAt > 0 && rightStartedAt <= 0) return -1; + if (leftStartedAt <= 0 && rightStartedAt > 0) return 1; + return left.index - right.index; + }) + .map(({ pkg }) => pkg); + const activeSet = new Set(active.map((pkg) => pkg.id)); + const rest = packages.filter((pkg) => !activeSet.has(pkg.id)); + + if (active.length === 0) { + return packages; + } return [...active, ...rest]; } diff --git a/src/renderer/shell/shell.css b/src/renderer/shell/shell.css index 17060ed..d8559e3 100644 --- a/src/renderer/shell/shell.css +++ b/src/renderer/shell/shell.css @@ -155,7 +155,7 @@ align-items: center; } -.md-update-trigger { +.md-update-trigger { display: inline-flex; height: 40px; min-width: 90px; @@ -164,8 +164,8 @@ justify-content: center; border: 0; border-radius: 6px; - background: var(--ui-primary); - color: var(--ui-primary-text); + background: var(--ui-update); + color: var(--ui-update-text); cursor: pointer; font-size: 14px; font-weight: 600; @@ -173,8 +173,8 @@ white-space: nowrap; } -.md-update-trigger:hover { - background: var(--ui-primary-hover); +.md-update-trigger:hover { + background: var(--ui-update-hover); } .md-update-trigger:focus-visible, @@ -308,13 +308,18 @@ line-height: 16px; } -.md-update-release-notes pre { - margin: 10px 0 0; - color: var(--ui-text-secondary); - font: inherit; - line-height: 20px; - white-space: pre-wrap; -} +.md-update-release-notes pre { + margin: 10px 0 0; + max-height: min(360px, 45vh); + overflow-y: auto; + padding-right: 8px; + color: var(--ui-text-secondary); + font: inherit; + line-height: 20px; + overscroll-behavior: contain; + scrollbar-gutter: stable; + white-space: pre-wrap; +} .md-update-dialog-actions { display: flex; diff --git a/src/renderer/theme.css b/src/renderer/theme.css index ab0e20a..d7f370e 100644 --- a/src/renderer/theme.css +++ b/src/renderer/theme.css @@ -15,6 +15,9 @@ --ui-primary: #D6D6D6; --ui-primary-hover: #E6E6E6; --ui-primary-text: #181A1F; + --ui-update: #BAD0FC; + --ui-update-hover: #8AA5DC; + --ui-update-text: #181A1F; --ui-accent: #4A4A4A; --ui-focus: #9AB8E8; --ui-speed-accent: #4ADE80; @@ -48,6 +51,9 @@ --ui-primary: #3A3A3A; --ui-primary-hover: #202020; --ui-primary-text: #FFFFFF; + --ui-update: #BAD0FC; + --ui-update-hover: #8AA5DC; + --ui-update-text: #181A1F; --ui-accent: #5E5E5E; --ui-focus: #24558D; --ui-speed-accent: #1E9E55; diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index b81aab9..210f3d1 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -139,6 +139,10 @@ export function compactDownloadStatus(value: string): string { if (/Download running\b/i.test(status)) return "Download running"; if (/^Passwort gefunden\b/i.test(status)) return "Passwort gefunden"; if (/^Password found\b/i.test(status)) return "Password found"; + const passwordCracking = status.match(/^(Passwort knacken|Cracking password):?\s*(\d+)%\s*(?:\((\d+\/\d+)\))?/i); + if (passwordCracking) { + return `${passwordCracking[1]}: ${passwordCracking[2]}%${passwordCracking[3] ? ` (${passwordCracking[3]})` : ""}`; + } if (/^Entpack-Fehler\b/i.test(status)) return "Entpack-Fehler"; if (/^Extraction error\b/i.test(status)) return "Extraction error"; const extractionPending = status.match(/^(Entpacken|Extracting)\s*-\s*(Ausstehend|Pending|Warten auf Parts|Waiting for parts)/i); diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index 79499b3..17b7f25 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -420,8 +420,8 @@ } .downloads-cell-slot > :is(.downloads-status-cell, .downloads-service-cell) { - justify-content: flex-start; - text-align: left; + justify-content: center; + text-align: center; } .downloads-cell, diff --git a/src/shared/hoster.ts b/src/shared/hoster.ts new file mode 100644 index 0000000..2694171 --- /dev/null +++ b/src/shared/hoster.ts @@ -0,0 +1,24 @@ +const DOMAIN_ALIASES: Readonly> = Object.freeze({ + "rapidgator.net": "rapidgator", + "rapidgator.asia": "rapidgator", + "rg.to": "rapidgator" +}); + +export function normalizeHosterHostname(hostname: string): string { + const normalized = hostname.trim().toLowerCase().replace(/^www\./, "").replace(/\.$/, ""); + for (const [domain, hoster] of Object.entries(DOMAIN_ALIASES)) { + if (normalized === domain || normalized.endsWith(`.${domain}`)) { + return hoster; + } + } + const parts = normalized.split(".").filter(Boolean); + return parts.length >= 2 ? parts[parts.length - 2] : normalized; +} + +export function extractHosterFromUrl(url: string): string { + try { + return normalizeHosterHostname(new URL(url).hostname); + } catch { + return ""; + } +} diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index 96a896e..17b4039 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -430,7 +430,7 @@ describe("debug-server", () => { const fixture = await createFixture(); const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, { headers: { - "X-Forwarded-For": "159.195.63.46" + "X-Forwarded-For": "203.0.113.46" } }); expect(response.ok).toBe(true); @@ -439,7 +439,7 @@ describe("debug-server", () => { const traceLogPath = getTraceLogPath(); expect(traceLogPath).toBeTruthy(); const traceText = fs.readFileSync(traceLogPath!, "utf8"); - expect(traceText).toContain("clientIp=159.195.63.46"); + expect(traceText).toContain("clientIp=203.0.113.46"); }); it("serves package details and package log by package query", async () => { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 1636b4a..bb288f8 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -252,6 +252,166 @@ describe("download manager", () => { expect(failures.has("realdebrid")).toBe(true); }); + it("releases only Mega-Debrid reset parks when a newly usable account appears", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-account-refresh-")); + tempDirs.push(root); + const storagePaths = createStoragePaths(path.join(root, "state")); + const previousSettings = { + ...defaultSettings(), + megaCredentials: "old@example.test:old-secret", + megaDebridApiEnabled: true + }; + const session = emptySession(); + const megaPackageId = "mega-refresh-package"; + const otherPackageId = "other-refresh-package"; + const megaItemId = "mega-refresh-item"; + const otherItemId = "other-refresh-item"; + const createdAt = Date.now(); + session.packageOrder = [megaPackageId, otherPackageId]; + session.packages[megaPackageId] = { + id: megaPackageId, + name: "Mega refresh", + status: "downloading", + itemIds: [megaItemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + } as any; + session.packages[otherPackageId] = { + id: otherPackageId, + name: "Other refresh", + status: "queued", + itemIds: [otherItemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + } as any; + session.items[megaItemId] = { + id: megaItemId, + packageId: megaPackageId, + url: "https://rapidgator.net/file/mega-refresh", + provider: "megadebrid-api", + status: "queued", + retries: 1, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "mega-refresh.rar", + targetPath: "", + resumable: true, + attempts: 0, + lastError: "limit", + fullStatus: "Mega-Debrid bis Tagesreset gesperrt, Pause 3600s", + createdAt, + updatedAt: createdAt + } as any; + session.items[otherItemId] = { + ...session.items[megaItemId], + id: otherItemId, + packageId: otherPackageId, + url: "https://ddownload.com/file/other-refresh", + provider: "realdebrid", + fileName: "other-refresh.rar", + fullStatus: "Netzwerk-Retry in 60s" + } as any; + session.running = true; + const manager = new DownloadManager(previousSettings, session, storagePaths); + session.running = true; + session.packages[megaPackageId].status = "downloading"; + session.items[megaItemId].status = "queued"; + session.items[megaItemId].fullStatus = "Mega-Debrid bis Tagesreset gesperrt, Pause 3600s"; + session.items[megaItemId].provider = "megadebrid-api"; + session.items[otherItemId].status = "queued"; + session.items[otherItemId].fullStatus = "Netzwerk-Retry in 60s"; + session.items[otherItemId].provider = "realdebrid"; + const retryAfter = (manager as any).retryAfterByItem as Map; + const retryState = (manager as any).retryStateByItem as Map; + retryAfter.set(megaItemId, Date.now() + 3_600_000); + retryAfter.set(otherItemId, Date.now() + 60_000); + retryState.set(megaItemId, { unrestrictRetries: 1 }); + retryState.set(otherItemId, { genericErrorRetries: 1 }); + const scheduler = vi.spyOn(manager as any, "ensureScheduler").mockResolvedValue(undefined); + vi.spyOn(manager as any, "cleanupExistingExtractedArchives").mockResolvedValue(0); + + manager.setSettings({ + ...previousSettings, + megaCredentials: "old@example.test:old-secret\nnew@example.test:new-secret", + megaDebridDisabledAccountIds: [getMegaDebridAccountId("old@example.test")] + }); + + expect(retryAfter.has(megaItemId)).toBe(false); + expect(retryState.has(megaItemId)).toBe(false); + expect(session.items[megaItemId].fullStatus).toBe("Wartet"); + expect(session.packages[megaPackageId].status).toBe("queued"); + expect(retryAfter.has(otherItemId)).toBe(true); + expect(retryState.has(otherItemId)).toBe(true); + expect(session.items[otherItemId].fullStatus).toBe("Netzwerk-Retry in 60s"); + expect(scheduler).toHaveBeenCalledTimes(1); + }); + + it("updates the package status atomically when an active item is queued for retry", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-retry-package-status-")); + tempDirs.push(root); + const storagePaths = createStoragePaths(path.join(root, "state")); + initPackageLogs(storagePaths.baseDir); + initItemLogs(storagePaths.baseDir); + const session = emptySession(); + const packageId = "retry-status-package"; + const itemId = "retry-status-item"; + const createdAt = Date.now(); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Retry status", + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + } as any; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://rapidgator.net/file/retry-status", + provider: "megadebrid-api", + status: "downloading", + retries: 1, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "retry-status.rar", + targetPath: "", + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Download läuft", + createdAt, + updatedAt: createdAt + } as any; + const manager = new DownloadManager(defaultSettings(), session, storagePaths); + session.packages[packageId].status = "downloading"; + const active = { + itemId, + packageId, + abortController: new AbortController(), + abortReason: "none", + resumable: true, + nonResumableCounted: false, + blockedOnDiskWrite: false, + blockedOnDiskSince: 0 + }; + + (manager as any).queueRetry(session.items[itemId], active, 60_000, "Mega-Debrid bis Tagesreset gesperrt, Pause 60s"); + + expect(session.items[itemId].status).toBe("queued"); + expect(session.packages[packageId].status).toBe("queued"); + }); + it("records history duration from the first actual package start", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-")); tempDirs.push(root); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 00c51e1..bf7fcc4 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -36,7 +36,12 @@ import { getPackageProgress, getPackageSizeProgress } from "../src/renderer/views/downloads/DownloadsTable"; -import { compactDownloadServiceLabel, normalizeDownloadServiceLabel } from "../src/renderer/download-format"; +import { + compactDownloadServiceLabel, + extractHoster, + formatHosterLabel, + normalizeDownloadServiceLabel +} from "../src/renderer/download-format"; import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue"; const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime(); @@ -71,6 +76,13 @@ describe("Downloadtabellen-Spalten", () => { expect(css).not.toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*background:/s); expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-table\.is-column-drag-active \[data-download-column\][^{]*\{[^}]*transition-duration:\s*220ms !important;/s); }); + + it("changes package collapse state only through user actions", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/renderer/App.tsx"), "utf8"); + + expect(source).not.toContain("autoExpandedPkgsRef"); + expect(source).not.toMatch(/isExtracting[\s\S]{0,800}setCollapsedPackages/); + }); }); describe("rollende Downloadkennzahlen", () => { @@ -175,12 +187,26 @@ describe("responsive Downloadstatus und Servicebezeichnungen", () => { expect(compactDownloadStatus("Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken - 1%"); expect(compactDownloadStatus("0/11 · Entpacken 53% (1/1) · scn2-httpv7-S01E102.rar")).toBe("Entpacken - 53%"); expect(compactDownloadStatus("Extracting 53% (1/1) · archive.rar")).toBe("Extracting - 53%"); + expect(compactDownloadStatus("Passwort knacken: 75% (3/4) · sau-geheim.part1.rar")).toBe("Passwort knacken: 75% (3/4)"); expect(compactDownloadStatus("Passwort gefunden · archive.part1.rar")).toBe("Passwort gefunden"); expect(compactDownloadStatus("Entpacken - Ausstehend · archive.part1.rar")).toBe("Entpacken - Ausstehend"); expect(compactDownloadStatus("Entpack-Fehler [archive.part1.rar]: Unerwartetes Dateiende")).toBe("Entpack-Fehler"); expect(compactDownloadStatus("Extraction error [archive.part1.rar]: Unexpected end of file")).toBe("Extraction error"); }); + it("normalizes every supported RapidGator domain to one hoster identity", () => { + const hosters = [ + extractHoster("https://rapidgator.net/file/one"), + extractHoster("https://rg.to/file/two"), + extractHoster("https://cdn.rg.to/file/three"), + extractHoster("https://rapidgator.asia/file/four") + ]; + + expect(hosters).toEqual(["rapidgator", "rapidgator", "rapidgator", "rapidgator"]); + expect(new Set(hosters).size).toBe(1); + expect(formatHosterLabel(hosters[1])).toEqual(expect.objectContaining({ compact: "RG", title: "RapidGator", iconSrc: expect.any(String) })); + }); + it("removes duplicated access-mode wording from service labels", () => { expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid (Web)"); expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid (API)"); @@ -690,7 +716,7 @@ describe("downloads view", () => { expect(css).toMatch(/\.downloads-link-state\.online\s*\{[^}]*background:\s*var\(--ui-success\);/s); expect(css).toMatch(/\.downloads-status-cell\s*\{[^}]*container-type:\s*inline-size;/s); expect(css).toMatch(/\.downloads-service-cell\s*\{[^}]*container-type:\s*inline-size;/s); - expect(css).toMatch(/\.downloads-cell-slot\s*>\s*:is\(\.downloads-status-cell, \.downloads-service-cell\)\s*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s); + expect(css).toMatch(/\.downloads-cell-slot\s*>\s*:is\(\.downloads-status-cell, \.downloads-service-cell\)\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s); expect(css).toMatch(/:is\(\.downloads-status-full, \.downloads-status-compact, \.downloads-service-full, \.downloads-service-compact\)\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/s); expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-status-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-status-compact[^{]*\{[^}]*display:\s*block;/s); expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-service-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-service-compact[^{]*\{[^}]*display:\s*block;/s); diff --git a/tests/package-order.test.ts b/tests/package-order.test.ts index b9200af..703f452 100644 --- a/tests/package-order.test.ts +++ b/tests/package-order.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import type { DownloadItem, PackageEntry } from "../src/shared/types"; -import { sortPackagesForDisplay } from "../src/renderer/package-order"; - -function createPackage(id: string, itemIds: string[]): PackageEntry { +import { sortPackagesForDisplay } from "../src/renderer/package-order"; + +function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry { const now = Date.now(); return { id, @@ -15,7 +15,8 @@ function createPackage(id: string, itemIds: string[]): PackageEntry { enabled: true, priority: "normal", createdAt: now, - updatedAt: now + updatedAt: now, + downloadStartedAt }; } @@ -90,7 +91,7 @@ describe("sortPackagesForDisplay", () => { expect(orderAfter).toEqual(orderBefore); }); - it("keeps package order untouched when auto sort is disabled", () => { + it("keeps package order untouched when auto sort is disabled", () => { const packages = [ createPackage("pkg-a", ["a1"]), createPackage("pkg-b", ["b1"]), @@ -104,6 +105,35 @@ describe("sortPackagesForDisplay", () => { const sorted = sortPackagesForDisplay(packages, items, true, false); - expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]); - }); -}); + expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]); + }); + + it("keeps every active package in activation order when a new package starts", () => { + const packages = [ + createPackage("pkg-new", ["new-item"], 200), + createPackage("pkg-existing", ["existing-item"], 100) + ]; + const items: Record = { + "new-item": createItem("new-item", "pkg-new", "downloading", 100), + "existing-item": createItem("existing-item", "pkg-existing", "downloading", 200) + }; + const sorted = sortPackagesForDisplay(packages, items, true, true); + + expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-existing", "pkg-new"]); + }); + + it("keeps queue order for active packages without a recorded start time", () => { + const packages = [ + createPackage("pkg-a", ["a1"]), + createPackage("pkg-b", ["b1"]), + createPackage("pkg-c", ["c1"]) + ]; + const items: Record = { + a1: createItem("a1", "pkg-a", "completed", 500), + b1: createItem("b1", "pkg-b", "downloading", 200), + c1: createItem("c1", "pkg-c", "downloading", 100) + }; + + expect(sortPackagesForDisplay(packages, items, true, true).map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-c", "pkg-a"]); + }); +}); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index ad97008..7f71ae7 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -3,6 +3,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { defaultSettings } from "../src/main/constants"; +import { buildAccountAddFields, createAccountDialogState } from "../src/renderer/App"; import { applyAccountEdit, createAccountEditState, @@ -905,6 +906,24 @@ describe("account workspace", () => { }); describe("settings App integration", () => { + it("keeps new Mega-Debrid credentials empty and never exposes stored accounts as an API key", () => { + const settings = { + ...defaultSettings(), + megaCredentials: "first@example.test:first-secret\nsecond@example.test:second-secret", + debridLinkApiKeys: "existing-debrid-link-key" + }; + const megaDialog = createAccountDialogState("create", "megadebrid-api", settings); + const megaFields = buildAccountAddFields(megaDialog); + const debridLinkFields = buildAccountAddFields(createAccountDialogState("create", "debridlink-api", settings)); + + expect(megaDialog.megaNewLogin).toBe(""); + expect(megaDialog.megaNewPassword).toBe(""); + expect(megaDialog.token).toBe(""); + expect(megaFields.map((field) => field.id)).toEqual(["megaNewLogin", "megaNewPassword", "dailyLimitGb"]); + expect(megaFields.map((field) => field.label)).not.toContain("Token / API-Key"); + expect(debridLinkFields.find((field) => field.id === "token")).toEqual(expect.objectContaining({ value: "" })); + }); + it("keeps specific persistence revision-safe when the draft changes in flight", () => { const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction"); expect(block).toContain("revisionAtStart"); diff --git a/tests/statistics-view.test.tsx b/tests/statistics-view.test.tsx index 66c56f0..3f5c100 100644 --- a/tests/statistics-view.test.tsx +++ b/tests/statistics-view.test.tsx @@ -438,7 +438,7 @@ describe("bandwidth chart palette", () => { const collector = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8"); expect(theme).toMatch(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--ui-focus\);/s); - expect(shell).toContain("color: var(--ui-primary-text);"); + expect(shell).toContain("color: var(--ui-update-text);"); expect(collector.match(/color:\s*var\(--ui-primary-text\);/g)).toHaveLength(3); }); diff --git a/tests/update-experience.test.tsx b/tests/update-experience.test.tsx index e68c7f5..1af1c80 100644 --- a/tests/update-experience.test.tsx +++ b/tests/update-experience.test.tsx @@ -1,144 +1,144 @@ -import { readFileSync } from "node:fs"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; -import { runLatestUpdateCheck, shouldApplyUpdateCheckResult } from "../src/renderer/App"; -import type { UpdateCheckResult } from "../src/shared/types"; -import { AppHeader } from "../src/renderer/shell/AppHeader"; -import { getUpdateDialogFocusTarget, UpdateExperience } from "../src/renderer/shell/UpdateExperience"; - -const callbacks = { - onOpen: () => {}, - onClose: () => {}, - onInstall: () => {}, - onLater: () => {} -}; - -describe("update experience", () => { - it("renders the available update and prompt as one accessible experience", () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain("aria-label=\"Update verfügbar\""); - expect(html).toContain("role=\"tooltip\""); - expect(html).toContain("Eine neue Version ist bereit. Klicke hier, um sie zu installieren."); - expect(html).toContain("role=\"dialog\""); - expect(html).toContain("aria-modal=\"true\""); - expect(html).toContain("Update installieren"); - expect(html).toContain("Jetzt aktualisieren"); - expect(html).toContain("Später"); - expect(html).toContain("Changes"); - expect(html).toContain(" { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain("aria-label=\"Update verfügbar\""); - expect(html).not.toContain("role=\"dialog\""); - }); - - it("renders active progress without controls that could close the installation", () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain("Update-Download: 47% (47 MB / 100 MB)"); - expect(html).toContain("aria-valuenow=\"47\""); - expect(html).not.toContain("Später"); - expect(html).not.toContain("Jetzt aktualisieren"); - expect(html).not.toContain("aria-label=\"Schließen\""); - }); - - it("preserves the original installation error in the reusable dialog", () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain("Update-Fehler: Originale Prüfsummenmeldung"); - expect(html).toContain("aria-label=\"Schließen\""); - }); - - it("renders nothing when no update is available and no dialog is active", () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toBe(""); - }); - - it("places the update affordance in the accessible global header action group", () => { - const html = renderToStaticMarkup( - - )} - onViewChange={() => {}} - /> - ); - - expect(html).toContain("role=\"group\""); - expect(html).toContain("aria-label=\"Globale Aktionen\""); - expect(html).toContain("aria-label=\"Update verfügbar\""); - }); - +import { readFileSync } from "node:fs"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { runLatestUpdateCheck, shouldApplyUpdateCheckResult } from "../src/renderer/App"; +import type { UpdateCheckResult } from "../src/shared/types"; +import { AppHeader } from "../src/renderer/shell/AppHeader"; +import { getUpdateDialogFocusTarget, UpdateExperience } from "../src/renderer/shell/UpdateExperience"; + +const callbacks = { + onOpen: () => {}, + onClose: () => {}, + onInstall: () => {}, + onLater: () => {} +}; + +describe("update experience", () => { + it("renders the available update and prompt as one accessible experience", () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("aria-label=\"Update verfügbar\""); + expect(html).toContain("role=\"tooltip\""); + expect(html).toContain("Eine neue Version ist bereit. Klicke hier, um sie zu installieren."); + expect(html).toContain("role=\"dialog\""); + expect(html).toContain("aria-modal=\"true\""); + expect(html).toContain("Update installieren"); + expect(html).toContain("Jetzt aktualisieren"); + expect(html).toContain("Später"); + expect(html).toContain("Changes"); + expect(html).toContain(" { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("aria-label=\"Update verfügbar\""); + expect(html).not.toContain("role=\"dialog\""); + }); + + it("renders active progress without controls that could close the installation", () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("Update-Download: 47% (47 MB / 100 MB)"); + expect(html).toContain("aria-valuenow=\"47\""); + expect(html).not.toContain("Später"); + expect(html).not.toContain("Jetzt aktualisieren"); + expect(html).not.toContain("aria-label=\"Schließen\""); + }); + + it("preserves the original installation error in the reusable dialog", () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("Update-Fehler: Originale Prüfsummenmeldung"); + expect(html).toContain("aria-label=\"Schließen\""); + }); + + it("renders nothing when no update is available and no dialog is active", () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toBe(""); + }); + + it("places the update affordance in the accessible global header action group", () => { + const html = renderToStaticMarkup( + + )} + onViewChange={() => {}} + /> + ); + + expect(html).toContain("role=\"group\""); + expect(html).toContain("aria-label=\"Globale Aktionen\""); + expect(html).toContain("aria-label=\"Update verfügbar\""); + }); + it("uses the specified transient and modal elevation tokens", () => { const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8"); @@ -146,37 +146,49 @@ describe("update experience", () => { expect(css).toMatch(/\.md-update-dialog\s*\{[^}]*box-shadow:\s*0 12px 40px rgb\(0 0 0 \/ 45%\)/s); }); - it("keeps forward and reverse tabbing inside the update dialog", () => { - expect(getUpdateDialogFocusTarget(false, -1, 4)).toBe(0); - expect(getUpdateDialogFocusTarget(true, -1, 4)).toBe(3); - expect(getUpdateDialogFocusTarget(false, 3, 4)).toBe(0); - expect(getUpdateDialogFocusTarget(true, 0, 4)).toBe(3); - expect(getUpdateDialogFocusTarget(false, 1, 4)).toBeNull(); - expect(getUpdateDialogFocusTarget(false, -1, 0)).toBeNull(); + it("uses a light-blue update affordance and a bounded scrollable changelog", () => { + const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8"); + const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8"); + + expect(theme).toMatch(/--ui-update:\s*#BAD0FC;/); + expect(theme).toMatch(/--ui-update-hover:\s*#8AA5DC;/); + expect(theme).toMatch(/--ui-update-text:\s*#181A1F;/); + expect(css).toMatch(/\.md-update-trigger\s*\{[^}]*background:\s*var\(--ui-update\);[^}]*color:\s*var\(--ui-update-text\);/s); + expect(css).toMatch(/\.md-update-trigger:hover\s*\{[^}]*background:\s*var\(--ui-update-hover\);/s); + expect(css).toMatch(/\.md-update-release-notes pre\s*\{[^}]*max-height:\s*min\(360px, 45vh\);[^}]*overflow-y:\s*auto;/s); }); - - it("rejects stale update-check completions without discarding the latest state", () => { - expect(shouldApplyUpdateCheckResult(4, 4)).toBe(true); - expect(shouldApplyUpdateCheckResult(3, 4)).toBe(false); - expect(shouldApplyUpdateCheckResult(4, 5)).toBe(false); - }); - - it("applies only the latest result when update checks complete out of order", async () => { - const generation = { current: 0 }; - const applied: string[] = []; - let finishStartup: ((result: UpdateCheckResult) => void) | undefined; - let finishManual: ((result: UpdateCheckResult) => void) | undefined; - const startup = new Promise((resolve) => { finishStartup = resolve; }); - const manual = new Promise((resolve) => { finishManual = resolve; }); - const apply = (result: UpdateCheckResult): void => { applied.push(result.latestTag); }; - - const startupRun = runLatestUpdateCheck(generation, () => startup, apply); - const manualRun = runLatestUpdateCheck(generation, () => manual, apply); - finishManual?.({ updateAvailable: true, currentVersion: "2.0.12", latestVersion: "9.9.9", latestTag: "v9.9.9", releaseUrl: "https://example.test/v9.9.9" }); - await manualRun; - finishStartup?.({ updateAvailable: false, currentVersion: "2.0.12", latestVersion: "2.0.12", latestTag: "v2.0.12", releaseUrl: "https://example.test/v2.0.12" }); - await startupRun; - - expect(applied).toEqual(["v9.9.9"]); - }); -}); + + it("keeps forward and reverse tabbing inside the update dialog", () => { + expect(getUpdateDialogFocusTarget(false, -1, 4)).toBe(0); + expect(getUpdateDialogFocusTarget(true, -1, 4)).toBe(3); + expect(getUpdateDialogFocusTarget(false, 3, 4)).toBe(0); + expect(getUpdateDialogFocusTarget(true, 0, 4)).toBe(3); + expect(getUpdateDialogFocusTarget(false, 1, 4)).toBeNull(); + expect(getUpdateDialogFocusTarget(false, -1, 0)).toBeNull(); + }); + + it("rejects stale update-check completions without discarding the latest state", () => { + expect(shouldApplyUpdateCheckResult(4, 4)).toBe(true); + expect(shouldApplyUpdateCheckResult(3, 4)).toBe(false); + expect(shouldApplyUpdateCheckResult(4, 5)).toBe(false); + }); + + it("applies only the latest result when update checks complete out of order", async () => { + const generation = { current: 0 }; + const applied: string[] = []; + let finishStartup: ((result: UpdateCheckResult) => void) | undefined; + let finishManual: ((result: UpdateCheckResult) => void) | undefined; + const startup = new Promise((resolve) => { finishStartup = resolve; }); + const manual = new Promise((resolve) => { finishManual = resolve; }); + const apply = (result: UpdateCheckResult): void => { applied.push(result.latestTag); }; + + const startupRun = runLatestUpdateCheck(generation, () => startup, apply); + const manualRun = runLatestUpdateCheck(generation, () => manual, apply); + finishManual?.({ updateAvailable: true, currentVersion: "2.0.12", latestVersion: "9.9.9", latestTag: "v9.9.9", releaseUrl: "https://example.test/v9.9.9" }); + await manualRun; + finishStartup?.({ updateAvailable: false, currentVersion: "2.0.12", latestVersion: "2.0.12", latestTag: "v2.0.12", releaseUrl: "https://example.test/v2.0.12" }); + await startupRun; + + expect(applied).toEqual(["v9.9.9"]); + }); +}); diff --git a/tests/update.test.ts b/tests/update.test.ts index 455fdb7..aa662b3 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -79,7 +79,7 @@ describe("update", () => { expect(result.updateAvailable).toBe(false); }); - it("picks setup executable asset from release list", async () => { + it("picks setup executable asset from release list", async () => { globalThis.fetch = (async (): Promise => new Response( JSON.stringify({ tag_name: "v9.9.9", @@ -106,7 +106,53 @@ describe("update", () => { expect(result.updateAvailable).toBe(true); expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe"); expect(result.setupAssetName).toBe("Real-Debrid-Downloader-Setup-9.9.9.exe"); - }); + }); + + it("combines every stable release note newer than the installed version", async () => { + const [major = 2, minor = 0, patch = 0] = parseVersionParts(APP_VERSION); + const version = (offset: number): string => `${major}.${minor}.${patch + offset}`; + const requestedUrls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + requestedUrls.push(url); + if (url.endsWith("/releases/latest")) { + return new Response(JSON.stringify({ + tag_name: `v${version(3)}`, + html_url: `https://github.com/owner/repo/releases/tag/v${version(3)}`, + body: "Latest changes", + assets: [] + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify([ + { tag_name: `v${version(3)}`, body: "Latest changes", draft: false, prerelease: false }, + { tag_name: `v${version(2)}`, body: "Middle changes", draft: false, prerelease: false }, + { tag_name: `v${version(1)}`, body: "First missed changes", draft: false, prerelease: false }, + { tag_name: `v${version(4)}`, body: "Draft changes", draft: true, prerelease: false }, + { tag_name: `v${version(5)}`, body: "Prerelease changes", draft: false, prerelease: true }, + { tag_name: `v${version(0)}`, body: "Installed changes", draft: false, prerelease: false }, + { tag_name: `v${version(-1)}`, body: "Older changes", draft: false, prerelease: false } + ]), { status: 200, headers: { "Content-Type": "application/json" } }); + }) as typeof fetch; + + const result = await checkGitHubUpdate("owner/repo"); + + expect(requestedUrls).toEqual([ + "https://api.github.com/repos/owner/repo/releases/latest", + "https://api.github.com/repos/owner/repo/releases?per_page=100&page=1" + ]); + expect(result.releaseNotes).toBe([ + `v${version(3)}`, + "Latest changes", + "", + `v${version(2)}`, + "Middle changes", + "", + `v${version(1)}`, + "First missed changes" + ].join("\n")); + }); it("uses silent NSIS install flags with auto-run after update", () => { expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]);