fix: polish download status and application menus
Animate nested application menus while keeping hidden actions out of keyboard navigation and respecting reduced-motion preferences. Normalize Mega-Debrid service labels, simplify active download and extraction statuses, and preserve full diagnostics in tooltips. Brand the Windows development runtime and isolate concurrent launchers with PID-aware cleanup and regression coverage.
This commit is contained in:
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||||
|
|
||||||
|
## [2.0.17] - 2026-08-10
|
||||||
|
|
||||||
|
### Interface fixes
|
||||||
|
|
||||||
|
- Added smooth horizontal opening and closing transitions to the Backup, Logs, Remote Support, and Diagnostics submenus.
|
||||||
|
- Kept nested menus mounted during closing so their exit motion remains visible instead of disappearing immediately.
|
||||||
|
- Preserved left-side submenu placement in narrow windows without restoring horizontal overflow.
|
||||||
|
- Removed hidden submenu actions from keyboard navigation and respected reduced-motion preferences.
|
||||||
|
- Simplified Mega-Debrid API service labels by removing redundant access-mode suffixes while retaining the complete source label as a tooltip.
|
||||||
|
- Reduced download and extraction status cells to the active operation and percentage while keeping diagnostic details in tooltips.
|
||||||
|
|
||||||
|
### Reliability and testing
|
||||||
|
|
||||||
|
- Branded the development Electron executable with the application name, version metadata, and product icon for Windows system dialogs.
|
||||||
|
- Made development launches resilient to stale executable locks by isolating each runtime executable.
|
||||||
|
- Added regression coverage for persistent nested-menu rendering, hidden interaction states, and the shared submenu transition.
|
||||||
|
- Added a Windows regression check for development executable metadata.
|
||||||
|
|
||||||
## [2.0.16] - 2026-08-10
|
## [2.0.16] - 2026-08-10
|
||||||
|
|
||||||
### Downloads and telemetry
|
### Downloads and telemetry
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.16",
|
"version": "2.0.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.16",
|
"version": "2.0.17",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.16",
|
"version": "2.0.17",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"dev:renderer": "vite --port 5180 --strictPort",
|
"dev:renderer": "vite --port 5180 --strictPort",
|
||||||
"visual:dev": "vite --config tests/visual/vite.config.mts --host 127.0.0.1 --port 5174 --strictPort",
|
"visual:dev": "vite --config tests/visual/vite.config.mts --host 127.0.0.1 --port 5174 --strictPort",
|
||||||
"dev:main:watch": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch",
|
"dev:main:watch": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch",
|
||||||
"dev:electron": "wait-on tcp:5180 file:build/main/main/main.js && cross-env NODE_ENV=development DEV_SERVER_PORT=5180 electron .",
|
"dev:electron": "wait-on tcp:5180 file:build/main/main/main.js && cross-env NODE_ENV=development DEV_SERVER_PORT=5180 tsx scripts/run-dev-electron.ts",
|
||||||
"build": "npm run build:main && npm run build:renderer",
|
"build": "npm run build:main && npm run build:renderer",
|
||||||
"build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
|
"build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
|
||||||
"build:renderer": "vite build",
|
"build:renderer": "vite build",
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { execFile, spawn } from "node:child_process";
|
||||||
|
import { copyFile, mkdir, readFile, readdir, rename, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const productName = "Multi-Debrid-Downloader";
|
||||||
|
const executableName = `${productName}.exe`;
|
||||||
|
|
||||||
|
async function brandExecutable(executable: string, icon: string, version: string): Promise<void> {
|
||||||
|
const editorName = process.arch === "x64" ? "rcedit-x64.exe" : "rcedit.exe";
|
||||||
|
const editor = path.resolve("node_modules", "rcedit", "bin", editorName);
|
||||||
|
await execFileAsync(editor, [
|
||||||
|
executable,
|
||||||
|
"--set-file-version", version,
|
||||||
|
"--set-product-version", version,
|
||||||
|
"--set-icon", icon,
|
||||||
|
"--set-version-string", "CompanyName", "Sucukdeluxe",
|
||||||
|
"--set-version-string", "FileDescription", productName,
|
||||||
|
"--set-version-string", "InternalName", productName,
|
||||||
|
"--set-version-string", "LegalCopyright", "Copyright © 2026 Sucukdeluxe",
|
||||||
|
"--set-version-string", "OriginalFilename", executableName,
|
||||||
|
"--set-version-string", "ProductName", productName
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareDevElectron(source: string, target: string, icon: string, version: string): Promise<string> {
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
const temporaryTarget = `${target}.${process.pid}.tmp.exe`;
|
||||||
|
await rm(temporaryTarget, { force: true });
|
||||||
|
await copyFile(source, temporaryTarget);
|
||||||
|
await brandExecutable(temporaryTarget, icon, version);
|
||||||
|
await rm(target, { force: true });
|
||||||
|
await rename(temporaryTarget, target);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readVersion(): Promise<string> {
|
||||||
|
const packageJson = JSON.parse(await readFile(path.resolve("package.json"), "utf8")) as { version: string };
|
||||||
|
return packageJson.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessRunning(pid: number): boolean {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return (error as NodeJS.ErrnoException).code === "EPERM";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeStaleDevExecutables(directory: string, activeTarget: string): Promise<void> {
|
||||||
|
const names = await readdir(directory).catch(() => []);
|
||||||
|
await Promise.all(names
|
||||||
|
.filter((name) => name === executableName || (name.startsWith(`${productName}-dev-`) && name.endsWith(".exe")))
|
||||||
|
.map(async (name) => {
|
||||||
|
const candidate = path.join(directory, name);
|
||||||
|
const pid = Number(name.match(/^Multi-Debrid-Downloader-dev-(\d+)\.exe(?:\.\d+\.tmp\.exe)?$/)?.[1] || 0);
|
||||||
|
if (candidate !== activeTarget && !isProcessRunning(pid)) {
|
||||||
|
await rm(candidate, { force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args[0] === "--prepare-only") {
|
||||||
|
const [, source, target, icon, version] = args;
|
||||||
|
if (!source || !target || !icon || !version) {
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await prepareDevElectron(source, target, icon, version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (args[0] === "--cleanup-only") {
|
||||||
|
const [, directory, activeTarget] = args;
|
||||||
|
if (!directory || !activeTarget) {
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await removeStaleDevExecutables(directory, activeTarget);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = require("electron") as string;
|
||||||
|
const directory = path.dirname(source);
|
||||||
|
const target = path.join(directory, `${productName}-dev-${process.pid}.exe`);
|
||||||
|
const icon = path.resolve("assets/app_icon.ico");
|
||||||
|
const version = await readVersion();
|
||||||
|
await removeStaleDevExecutables(directory, target);
|
||||||
|
await prepareDevElectron(source, target, icon, version);
|
||||||
|
|
||||||
|
const child = spawn(target, ["."], { stdio: "inherit", windowsHide: false });
|
||||||
|
child.on("close", async (code, signal) => {
|
||||||
|
await rm(target, { force: true }).catch(() => undefined);
|
||||||
|
process.exitCode = code ?? (signal ? 1 : 0);
|
||||||
|
});
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
if (!child.killed) {
|
||||||
|
child.kill(signal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void run().catch((error: unknown) => {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
+20
-12
@@ -5679,15 +5679,17 @@ export function App(): ReactElement {
|
|||||||
onMouseLeave={() => setOpenSubmenu(null)}
|
onMouseLeave={() => setOpenSubmenu(null)}
|
||||||
>
|
>
|
||||||
<button className="menu-submenu-trigger">Sicherung</button>
|
<button className="menu-submenu-trigger">Sicherung</button>
|
||||||
{openSubmenu === "sicherung" && (
|
<div
|
||||||
<div className="menu-submenu-dropdown">
|
aria-hidden={openSubmenu !== "sicherung"}
|
||||||
|
{...(openSubmenu !== "sicherung" ? { inert: "" } : {})}
|
||||||
|
className={`menu-submenu-dropdown${openSubmenu === "sicherung" ? " is-open" : ""}`}
|
||||||
|
>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onExportBackup(); }}>Exportieren</button>
|
<button className="menu-dropdown-item" onClick={() => { void onExportBackup(); }}>Exportieren</button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onImportBackup(); }}>Importieren</button>
|
<button className="menu-dropdown-item" onClick={() => { void onImportBackup(); }}>Importieren</button>
|
||||||
<div className="menu-separator" />
|
<div className="menu-separator" />
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onCreateOnlineBackup(); }}>Online-Schlüssel exportieren</button>
|
<button className="menu-dropdown-item" onClick={() => { void onCreateOnlineBackup(); }}>Online-Schlüssel exportieren</button>
|
||||||
<button className="menu-dropdown-item" onClick={onOpenOnlineBackupImport}>Online-Schlüssel importieren</button>
|
<button className="menu-dropdown-item" onClick={onOpenOnlineBackupImport}>Online-Schlüssel importieren</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="menu-separator" />
|
<div className="menu-separator" />
|
||||||
<button className="menu-dropdown-item" onClick={onMenuRestart}>
|
<button className="menu-dropdown-item" onClick={onMenuRestart}>
|
||||||
@@ -5827,15 +5829,17 @@ export function App(): ReactElement {
|
|||||||
onMouseLeave={() => setOpenSubmenu(null)}
|
onMouseLeave={() => setOpenSubmenu(null)}
|
||||||
>
|
>
|
||||||
<button className="menu-submenu-trigger">Logs öffnen</button>
|
<button className="menu-submenu-trigger">Logs öffnen</button>
|
||||||
{openSubmenu === "hilfe-log" && (
|
<div
|
||||||
<div className="menu-submenu-dropdown">
|
aria-hidden={openSubmenu !== "hilfe-log"}
|
||||||
|
{...(openSubmenu !== "hilfe-log" ? { inert: "" } : {})}
|
||||||
|
className={`menu-submenu-dropdown${openSubmenu === "hilfe-log" ? " is-open" : ""}`}
|
||||||
|
>
|
||||||
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}><span>Haupt-Log</span></button>
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}><span>Haupt-Log</span></button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openAuditLog().catch(() => {}); }}><span>Audit-Log</span></button>
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openAuditLog().catch(() => {}); }}><span>Audit-Log</span></button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openRenameLog().catch(() => {}); }}><span>Rename-Log</span></button>
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openRenameLog().catch(() => {}); }}><span>Rename-Log</span></button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}><span>Session-Log</span></button>
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}><span>Session-Log</span></button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openTraceLog().catch(() => {}); }}><span>Trace-Log</span></button>
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openTraceLog().catch(() => {}); }}><span>Trace-Log</span></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="menu-submenu"
|
className="menu-submenu"
|
||||||
@@ -5843,13 +5847,15 @@ export function App(): ReactElement {
|
|||||||
onMouseLeave={() => setOpenSubmenu(null)}
|
onMouseLeave={() => setOpenSubmenu(null)}
|
||||||
>
|
>
|
||||||
<button className="menu-submenu-trigger">Remote-Support</button>
|
<button className="menu-submenu-trigger">Remote-Support</button>
|
||||||
{openSubmenu === "hilfe-remote" && (
|
<div
|
||||||
<div className="menu-submenu-dropdown">
|
aria-hidden={openSubmenu !== "hilfe-remote"}
|
||||||
|
{...(openSubmenu !== "hilfe-remote" ? { inert: "" } : {})}
|
||||||
|
className={`menu-submenu-dropdown${openSubmenu === "hilfe-remote" ? " is-open" : ""}`}
|
||||||
|
>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose …</span></button>
|
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose …</span></button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
|
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
|
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="menu-separator" />
|
<div className="menu-separator" />
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onShowRecentErrors(); }}>
|
<button className="menu-dropdown-item" onClick={() => { void onShowRecentErrors(); }}>
|
||||||
@@ -5861,12 +5867,14 @@ export function App(): ReactElement {
|
|||||||
onMouseLeave={() => setOpenSubmenu(null)}
|
onMouseLeave={() => setOpenSubmenu(null)}
|
||||||
>
|
>
|
||||||
<button className="menu-submenu-trigger">Diagnose</button>
|
<button className="menu-submenu-trigger">Diagnose</button>
|
||||||
{openSubmenu === "hilfe-diagnose" && (
|
<div
|
||||||
<div className="menu-submenu-dropdown">
|
aria-hidden={openSubmenu !== "hilfe-diagnose"}
|
||||||
|
{...(openSubmenu !== "hilfe-diagnose" ? { inert: "" } : {})}
|
||||||
|
className={`menu-submenu-dropdown${openSubmenu === "hilfe-diagnose" ? " is-open" : ""}`}
|
||||||
|
>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onRunDebugSetupCheck(); }}><span>Debug-Setup prüfen</span></button>
|
<button className="menu-dropdown-item" onClick={() => { void onRunDebugSetupCheck(); }}><span>Debug-Setup prüfen</span></button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onRotateDebugToken(); }}><span>Debug-Token rotieren</span></button>
|
<button className="menu-dropdown-item" onClick={() => { void onRotateDebugToken(); }}><span>Debug-Token rotieren</span></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="menu-separator" />
|
<div className="menu-separator" />
|
||||||
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}>
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function compactProviderLabels(labels: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeDownloadServiceLabel(label: string): string {
|
export function normalizeDownloadServiceLabel(label: string): string {
|
||||||
return [...new Set(label.split(",").map((entry) => entry.trim().replace(/\s+(Web|API)\s+\(\1 Account\)$/i, " $1")).filter(Boolean))].join(", ");
|
return [...new Set(label.split(",").map((entry) => entry.trim().replace(/^(Mega-Debrid)\s+(Web|API)(?:\s+\([^)]*\))?$/i, "$1 $2")).filter(Boolean))].join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compactDownloadServiceLabel(label: string): string {
|
export function compactDownloadServiceLabel(label: string): string {
|
||||||
|
|||||||
@@ -612,6 +612,22 @@
|
|||||||
.md-application-menu-tree .menu-submenu-dropdown {
|
.md-application-menu-tree .menu-submenu-dropdown {
|
||||||
right: 100%;
|
right: 100%;
|
||||||
left: auto;
|
left: auto;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(8px);
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity 160ms ease,
|
||||||
|
transform 220ms cubic-bezier(0.22, 0.76, 0.22, 1),
|
||||||
|
visibility 0s linear 220ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-application-menu-tree .menu-submenu-dropdown.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition-delay: 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-overlay-host {
|
.md-overlay-host {
|
||||||
@@ -909,4 +925,13 @@
|
|||||||
.md-application-menu-tree .menu-dropdown.is-open {
|
.md-application-menu-tree .menu-dropdown.is-open {
|
||||||
transition-delay: 0s !important;
|
transition-delay: 0s !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-application-menu-tree .menu-submenu-dropdown {
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
transition-delay: 0s !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-application-menu-tree .menu-submenu-dropdown.is-open {
|
||||||
|
transition-delay: 0s !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,9 +122,13 @@ function progress(value: number): number {
|
|||||||
export function compactDownloadStatus(value: string): string {
|
export function compactDownloadStatus(value: string): string {
|
||||||
const status = value.trim();
|
const status = value.trim();
|
||||||
if (/Link wird umgewandelt/i.test(status)) return "Umwandeln";
|
if (/Link wird umgewandelt/i.test(status)) return "Umwandeln";
|
||||||
if (/Download läuft\b/i.test(status)) return "DL läuft";
|
if (/Download läuft\b/i.test(status)) return "Download läuft";
|
||||||
const extracting = status.match(/(Entpacken\s+\d+%)/i);
|
if (/Download running\b/i.test(status)) return "Download running";
|
||||||
return extracting?.[1] ?? status;
|
const extracting = status.match(/Entpacken\s+(\d+)%/i);
|
||||||
|
if (extracting) return `Entpacken - ${extracting[1]}%`;
|
||||||
|
const extractingEnglish = status.match(/Extracting\s+(\d+)%/i);
|
||||||
|
if (extractingEnglish) return `Extracting - ${extractingEnglish[1]}%`;
|
||||||
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DownloadMeter({ value, text }: { value: number; text: string }): ReactElement {
|
function DownloadMeter({ value, text }: { value: number; text: string }): ReactElement {
|
||||||
@@ -140,10 +144,11 @@ function DownloadMeter({ value, text }: { value: number; text: string }): ReactE
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DownloadStatusCell({ status, title }: { status: string; title?: string }): ReactElement {
|
function DownloadStatusCell({ status, title }: { status: string; title?: string }): ReactElement {
|
||||||
|
const visibleStatus = compactDownloadStatus(status);
|
||||||
return (
|
return (
|
||||||
<span aria-label={status} className="downloads-cell downloads-status-cell" title={title || status}>
|
<span aria-label={visibleStatus} className="downloads-cell downloads-status-cell" title={title || status}>
|
||||||
<span aria-hidden="true" className="downloads-status-full">{status}</span>
|
<span aria-hidden="true" className="downloads-status-full">{visibleStatus}</span>
|
||||||
<span aria-hidden="true" className="downloads-status-compact">{compactDownloadStatus(status)}</span>
|
<span aria-hidden="true" className="downloads-status-compact">{visibleStatus}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -410,8 +415,12 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
|||||||
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
|
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
|
||||||
if (column === "status") {
|
if (column === "status") {
|
||||||
const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null;
|
const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null;
|
||||||
const status = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
|
const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
|
||||||
const title = audio?.tooltip ? `${status}\n${audio.tooltip}` : status;
|
const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating");
|
||||||
|
const status = entry.postProcessLabel && /Entpacken\s+\d+%/i.test(entry.postProcessLabel)
|
||||||
|
? entry.postProcessLabel
|
||||||
|
: downloading ? "Download läuft" : details;
|
||||||
|
const title = audio?.tooltip ? `${details}\n${audio.tooltip}` : details;
|
||||||
return <DownloadStatusCell status={status} title={title} />;
|
return <DownloadStatusCell status={status} title={title} />;
|
||||||
}
|
}
|
||||||
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
|
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
|
||||||
|
|||||||
@@ -31,10 +31,17 @@ describe("desktop shell", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps application submenus visible inside narrow right-aligned windows", () => {
|
it("keeps application submenus visible inside narrow right-aligned windows", () => {
|
||||||
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source.match(/aria-hidden=\{openSubmenu !==/g)).toHaveLength(4);
|
||||||
|
expect(source.match(/inert: ""/g)).toHaveLength(4);
|
||||||
|
expect(source.match(/menu-submenu-dropdown\$\{openSubmenu ===/g)).toHaveLength(4);
|
||||||
|
expect(source).not.toMatch(/\{openSubmenu === "(?:sicherung|hilfe-log|hilfe-remote|hilfe-diagnose)" && \(/);
|
||||||
expect(shellCss).toMatch(/\.md-application-menu-tree :where\(\.menu-dropdown, \.menu-submenu-dropdown\)\s*\{[^}]*overflow:\s*visible;/s);
|
expect(shellCss).toMatch(/\.md-application-menu-tree :where\(\.menu-dropdown, \.menu-submenu-dropdown\)\s*\{[^}]*overflow:\s*visible;/s);
|
||||||
expect(shellCss).toMatch(/\.md-application-menu-tree \.menu-submenu-dropdown\s*\{[^}]*right:\s*100%;[^}]*left:\s*auto;/s);
|
expect(shellCss).toMatch(/\.md-application-menu-tree \.menu-submenu-dropdown\s*\{[^}]*right:\s*100%;[^}]*left:\s*auto;[^}]*opacity:\s*0;[^}]*transform:\s*translateX\(8px\);[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none;[^}]*transition:[^}]*transform 220ms cubic-bezier\(0\.22, 0\.76, 0\.22, 1\)/s);
|
||||||
|
expect(shellCss).toMatch(/\.md-application-menu-tree \.menu-submenu-dropdown\.is-open\s*\{[^}]*opacity:\s*1;[^}]*transform:\s*translateX\(0\);[^}]*visibility:\s*visible;[^}]*pointer-events:\s*auto;/s);
|
||||||
|
expect(shellCss).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-application-menu-tree \.menu-submenu-dropdown\s*\{[^}]*transition-duration:\s*0\.01ms !important;[^}]*transition-delay:\s*0s !important;/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the product asset in the header brand", () => {
|
it("uses the product asset in the header brand", () => {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) {
|
||||||
|
rmSync(root, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("development Electron branding", () => {
|
||||||
|
it("uses a unique executable for each development launcher", () => {
|
||||||
|
const source = readFileSync(path.resolve("scripts/run-dev-electron.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain("`${productName}-dev-${process.pid}.exe`");
|
||||||
|
expect(source).not.toContain("path.join(path.dirname(source), executableName)");
|
||||||
|
expect(source).toContain("process.kill(pid, 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.runIf(process.platform === "win32")("prepares a branded development executable", () => {
|
||||||
|
const root = mkdtempSync(path.join(tmpdir(), "mdd-dev-electron-"));
|
||||||
|
roots.push(root);
|
||||||
|
const source = path.resolve("node_modules/electron/dist/electron.exe");
|
||||||
|
const target = path.join(root, "Multi-Debrid-Downloader.exe");
|
||||||
|
const icon = path.resolve("assets/app_icon.ico");
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
path.resolve("node_modules/tsx/dist/cli.mjs"),
|
||||||
|
"scripts/run-dev-electron.ts",
|
||||||
|
"--prepare-only",
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
icon,
|
||||||
|
"2.0.17"
|
||||||
|
],
|
||||||
|
{ cwd: path.resolve("."), encoding: "utf8" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status, result.stderr || result.stdout).toBe(0);
|
||||||
|
const metadata = JSON.parse(execFileSync(
|
||||||
|
"powershell.exe",
|
||||||
|
[
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
"$v=(Get-Item -LiteralPath $env:MDD_TEST_EXE).VersionInfo; [pscustomobject]@{FileDescription=$v.FileDescription;ProductName=$v.ProductName;OriginalFilename=$v.OriginalFilename;FileVersion=$v.FileVersion;ProductVersion=$v.ProductVersion}|ConvertTo-Json -Compress"
|
||||||
|
],
|
||||||
|
{ encoding: "utf8", env: { ...process.env, MDD_TEST_EXE: target } }
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(metadata).toEqual({
|
||||||
|
FileDescription: "Multi-Debrid-Downloader",
|
||||||
|
ProductName: "Multi-Debrid-Downloader",
|
||||||
|
OriginalFilename: "Multi-Debrid-Downloader.exe",
|
||||||
|
FileVersion: "2.0.17",
|
||||||
|
ProductVersion: "2.0.17"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves executables owned by active launchers while removing stale ones", () => {
|
||||||
|
const root = mkdtempSync(path.join(tmpdir(), "mdd-dev-cleanup-"));
|
||||||
|
roots.push(root);
|
||||||
|
const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
|
||||||
|
const activeTarget = path.join(root, `Multi-Debrid-Downloader-dev-${process.pid}.exe`);
|
||||||
|
const liveTarget = path.join(root, `Multi-Debrid-Downloader-dev-${owner.pid}.exe`);
|
||||||
|
const staleTarget = path.join(root, "Multi-Debrid-Downloader-dev-999999.exe");
|
||||||
|
writeFileSync(activeTarget, "active");
|
||||||
|
writeFileSync(liveTarget, "live");
|
||||||
|
writeFileSync(staleTarget, "stale");
|
||||||
|
try {
|
||||||
|
const result = spawnSync(process.execPath, [
|
||||||
|
path.resolve("node_modules/tsx/dist/cli.mjs"),
|
||||||
|
"scripts/run-dev-electron.ts",
|
||||||
|
"--cleanup-only",
|
||||||
|
root,
|
||||||
|
activeTarget
|
||||||
|
], { cwd: path.resolve("."), encoding: "utf8" });
|
||||||
|
|
||||||
|
expect(result.status, result.stderr || result.stdout).toBe(0);
|
||||||
|
expect(existsSync(activeTarget)).toBe(true);
|
||||||
|
expect(existsSync(liveTarget)).toBe(true);
|
||||||
|
expect(existsSync(staleTarget)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
owner.kill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -151,14 +151,17 @@ describe("laufender Queue-Linkzähler", () => {
|
|||||||
describe("responsive Downloadstatus und Servicebezeichnungen", () => {
|
describe("responsive Downloadstatus und Servicebezeichnungen", () => {
|
||||||
it("keeps full status details while providing compact table text", () => {
|
it("keeps full status details while providing compact table text", () => {
|
||||||
expect(compactDownloadStatus("Link wird umgewandelt")).toBe("Umwandeln");
|
expect(compactDownloadStatus("Link wird umgewandelt")).toBe("Umwandeln");
|
||||||
expect(compactDownloadStatus("Download läuft (Mega-Debrid)")).toBe("DL läuft");
|
expect(compactDownloadStatus("Download läuft (Mega-Debrid API)")).toBe("Download läuft");
|
||||||
expect(compactDownloadStatus("Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken 1%");
|
expect(compactDownloadStatus("Download running (Mega-Debrid API)")).toBe("Download running");
|
||||||
expect(compactDownloadStatus("0/11 · Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken 1%");
|
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%");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes duplicated access-mode wording from service labels", () => {
|
it("removes duplicated access-mode wording from service labels", () => {
|
||||||
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid Web");
|
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid Web");
|
||||||
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid API");
|
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid API");
|
||||||
|
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Access)")).toBe("Mega-Debrid API");
|
||||||
expect(normalizeDownloadServiceLabel("Real-Debrid (Web Account)")).toBe("Real-Debrid (Web Account)");
|
expect(normalizeDownloadServiceLabel("Real-Debrid (Web Account)")).toBe("Real-Debrid (Web Account)");
|
||||||
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid Web, Mega-Debrid API");
|
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid Web, Mega-Debrid API");
|
||||||
expect(compactDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid");
|
expect(compactDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid");
|
||||||
@@ -827,10 +830,11 @@ describe("download table row contracts", () => {
|
|||||||
selected: false
|
selected: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(html).toContain('aria-label="Download läuft (Mega-Debrid)"');
|
expect(html).toContain('aria-label="Download läuft"');
|
||||||
expect(html).toContain('class="downloads-status-full"');
|
expect(html).toContain('class="downloads-status-full"');
|
||||||
expect(html).toContain('class="downloads-status-compact"');
|
expect(html).toContain('class="downloads-status-compact"');
|
||||||
expect(html).toContain("DL läuft");
|
expect(html.match(/>Download läuft<\/span>/g)).toHaveLength(2);
|
||||||
|
expect(html).toContain('title="Download läuft (Mega-Debrid)"');
|
||||||
expect(html).toContain('title="Mega-Debrid Web (Web Account)"');
|
expect(html).toContain('title="Mega-Debrid Web (Web Account)"');
|
||||||
expect(html).toContain('class="downloads-service-full">Mega-Debrid Web</span>');
|
expect(html).toContain('class="downloads-service-full">Mega-Debrid Web</span>');
|
||||||
expect(html).toContain('class="downloads-service-compact">Mega-Debrid</span>');
|
expect(html).toContain('class="downloads-service-compact">Mega-Debrid</span>');
|
||||||
@@ -968,6 +972,24 @@ describe("download table row contracts", () => {
|
|||||||
expect(html).toMatch(/title="0\/1 · Entpacken 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
|
expect(html).toMatch(/title="0\/1 · Entpacken 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows only the operation in an actively downloading package status", () => {
|
||||||
|
const activePackage = pkg("active-package", "Active package", ["active-item"]);
|
||||||
|
const html = renderToStaticMarkup(PackageCardContent({
|
||||||
|
actions: createActions(),
|
||||||
|
columnOrder: ["status"],
|
||||||
|
editing: false,
|
||||||
|
editingName: "",
|
||||||
|
gridTemplate: "220px",
|
||||||
|
packageSpeedBps: 1_000,
|
||||||
|
row: { package: activePackage, items: [item("active-item", activePackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid API)" })], collapsed: true },
|
||||||
|
selectedIds: new Set<string>(),
|
||||||
|
selectedVersion: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(html.match(/>Download läuft<\/span>/g)).toHaveLength(2);
|
||||||
|
expect(html).toContain('title="0/1"');
|
||||||
|
});
|
||||||
|
|
||||||
it("commits Enter and the resulting Blur rename sequence exactly once", () => {
|
it("commits Enter and the resulting Blur rename sequence exactly once", () => {
|
||||||
const commits: string[] = [];
|
const commits: string[] = [];
|
||||||
const model = withRuntime(createInput());
|
const model = withRuntime(createInput());
|
||||||
|
|||||||
Reference in New Issue
Block a user