Add bandwidth statistics tab with live chart
- Add new Statistics tab between Downloads and Settings - Implement real-time bandwidth chart using Canvas (60s history) - Add session overview with 8 stats cards (speed, downloaded, files, packages, etc.) - Add provider statistics with progress bars - Add getSessionStats IPC endpoint - Support dark/light theme in chart rendering
This commit is contained in:
parent
f5d7ee4d1a
commit
a0cdac87e8
@ -5,6 +5,7 @@ import {
|
||||
AppSettings,
|
||||
DuplicatePolicy,
|
||||
ParsedPackageInput,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
UiSnapshot,
|
||||
@ -225,6 +226,10 @@ export class AppController {
|
||||
return this.manager.importQueue(json);
|
||||
}
|
||||
|
||||
public getSessionStats(): SessionStats {
|
||||
return this.manager.getSessionStats();
|
||||
}
|
||||
|
||||
public shutdown(): void {
|
||||
abortActiveUpdateDownload();
|
||||
this.manager.prepareForShutdown();
|
||||
|
||||
@ -4737,4 +4737,79 @@ export class DownloadManager extends EventEmitter {
|
||||
this.persistNow();
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
public getSessionStats(): import("../shared/types").SessionStats {
|
||||
const now = nowMs();
|
||||
this.pruneSpeedEvents(now);
|
||||
|
||||
const bandwidthSamples: import("../shared/types").BandwidthSample[] = [];
|
||||
for (let i = this.speedEventsHead; i < this.speedEvents.length; i += 1) {
|
||||
const event = this.speedEvents[i];
|
||||
if (event) {
|
||||
bandwidthSamples.push({
|
||||
timestamp: event.at,
|
||||
speedBps: event.bytes * 3
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const paused = this.session.running && this.session.paused;
|
||||
const currentSpeedBps = paused ? 0 : this.speedBytesLastWindow / 3;
|
||||
|
||||
let totalBytes = 0;
|
||||
let maxSpeed = 0;
|
||||
for (let i = this.speedEventsHead; i < this.speedEvents.length; i += 1) {
|
||||
const event = this.speedEvents[i];
|
||||
if (event) {
|
||||
totalBytes += event.bytes;
|
||||
const speed = event.bytes * 3;
|
||||
if (speed > maxSpeed) {
|
||||
maxSpeed = speed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sessionDurationSeconds = this.session.runStartedAt > 0
|
||||
? Math.max(0, Math.floor((now - this.session.runStartedAt) / 1000))
|
||||
: 0;
|
||||
|
||||
const averageSpeedBps = sessionDurationSeconds > 0
|
||||
? Math.floor(this.session.totalDownloadedBytes / sessionDurationSeconds)
|
||||
: 0;
|
||||
|
||||
let totalDownloads = 0;
|
||||
let completedDownloads = 0;
|
||||
let failedDownloads = 0;
|
||||
let activeDownloads = 0;
|
||||
let queuedDownloads = 0;
|
||||
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
totalDownloads += 1;
|
||||
if (item.status === "completed") {
|
||||
completedDownloads += 1;
|
||||
} else if (item.status === "failed") {
|
||||
failedDownloads += 1;
|
||||
} else if (item.status === "downloading" || item.status === "validating") {
|
||||
activeDownloads += 1;
|
||||
} else if (item.status === "queued" || item.status === "reconnect_wait") {
|
||||
queuedDownloads += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bandwidth: {
|
||||
samples: bandwidthSamples.slice(-120),
|
||||
currentSpeedBps: Math.floor(currentSpeedBps),
|
||||
averageSpeedBps,
|
||||
maxSpeedBps: Math.floor(maxSpeed),
|
||||
totalBytesSession: this.session.totalDownloadedBytes,
|
||||
sessionDurationSeconds
|
||||
},
|
||||
totalDownloads,
|
||||
completedDownloads,
|
||||
failedDownloads,
|
||||
activeDownloads,
|
||||
queuedDownloads
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -343,6 +343,7 @@ function registerIpcHandlers(): void {
|
||||
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||
return result.canceled ? [] : result.filePaths;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
|
||||
|
||||
controller.onState = (snapshot) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
AddLinksPayload,
|
||||
AppSettings,
|
||||
DuplicatePolicy,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
UiSnapshot,
|
||||
@ -40,6 +41,7 @@ const api: ElectronApi = {
|
||||
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
||||
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||
|
||||
@ -15,7 +15,7 @@ import type {
|
||||
UpdateInstallProgress
|
||||
} from "../shared/types";
|
||||
|
||||
type Tab = "collector" | "downloads" | "settings";
|
||||
type Tab = "collector" | "downloads" | "statistics" | "settings";
|
||||
|
||||
interface CollectorTab {
|
||||
id: string;
|
||||
@ -91,6 +91,186 @@ function humanSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
interface BandwidthChartProps {
|
||||
items: Record<string, DownloadItem>;
|
||||
running: boolean;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
const BandwidthChart = memo(function BandwidthChart({ items, running, paused }: BandwidthChartProps): ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const speedHistoryRef = useRef<{ time: number; speed: number }[]>([]);
|
||||
const lastUpdateRef = useRef<number>(0);
|
||||
const [, forceUpdate] = useState(0);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
|
||||
const drawChart = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return;
|
||||
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const padding = { top: 20, right: 20, bottom: 30, left: 60 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
|
||||
const gridColor = isDark ? "rgba(35, 57, 84, 0.5)" : "rgba(199, 213, 234, 0.5)";
|
||||
const textColor = isDark ? "#90a4bf" : "#4e6482";
|
||||
const accentColor = isDark ? "#38bdf8" : "#1168d9";
|
||||
const fillColor = isDark ? "rgba(56, 189, 248, 0.15)" : "rgba(17, 104, 217, 0.15)";
|
||||
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i += 1) {
|
||||
const y = padding.top + (chartHeight / 5) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(width - padding.right, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const history = speedHistoryRef.current;
|
||||
const now = Date.now();
|
||||
const maxTime = now;
|
||||
const minTime = now - 60000;
|
||||
|
||||
let maxSpeed = 0;
|
||||
for (const point of history) {
|
||||
if (point.speed > maxSpeed) maxSpeed = point.speed;
|
||||
}
|
||||
maxSpeed = Math.max(maxSpeed, 1024 * 1024);
|
||||
const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed)));
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = "11px 'Manrope', sans-serif";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
for (let i = 0; i <= 5; i += 1) {
|
||||
const y = padding.top + (chartHeight / 5) * i;
|
||||
const speedVal = niceMax * (1 - i / 5);
|
||||
ctx.fillText(formatSpeedMbps(speedVal), padding.left - 8, y);
|
||||
}
|
||||
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText("0s", padding.left, height - padding.bottom + 8);
|
||||
ctx.fillText("30s", padding.left + chartWidth / 2, height - padding.bottom + 8);
|
||||
ctx.fillText("60s", width - padding.right, height - padding.bottom + 8);
|
||||
|
||||
if (history.length < 2) {
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = "13px 'Manrope', sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(running ? (paused ? "Pausiert" : "Sammle Daten...") : "Download starten fur Statistiken", width / 2, height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const points: { x: number; y: number }[] = [];
|
||||
for (const point of history) {
|
||||
const x = padding.left + ((point.time - minTime) / 60000) * chartWidth;
|
||||
const y = padding.top + chartHeight - (point.speed / niceMax) * chartHeight;
|
||||
points.push({ x, y });
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
ctx.lineTo(points[i].x, points[i].y);
|
||||
}
|
||||
ctx.lineTo(points[points.length - 1].x, padding.top + chartHeight);
|
||||
ctx.lineTo(points[0].x, padding.top + chartHeight);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
ctx.lineTo(points[i].x, points[i].y);
|
||||
}
|
||||
ctx.strokeStyle = accentColor;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
const lastPoint = points[points.length - 1];
|
||||
ctx.beginPath();
|
||||
ctx.arc(lastPoint.x, lastPoint.y, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = accentColor;
|
||||
ctx.fill();
|
||||
}, [running, paused]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastUpdateRef.current >= 250) {
|
||||
forceUpdate((n) => n + 1);
|
||||
}
|
||||
}, 250);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const totalSpeed = Object.values(items)
|
||||
.filter((item) => item.status === "downloading")
|
||||
.reduce((sum, item) => sum + (item.speedBps || 0), 0);
|
||||
|
||||
const history = speedHistoryRef.current;
|
||||
history.push({ time: now, speed: paused ? 0 : totalSpeed });
|
||||
|
||||
const cutoff = now - 60000;
|
||||
while (history.length > 0 && history[0].time < cutoff) {
|
||||
history.shift();
|
||||
}
|
||||
|
||||
lastUpdateRef.current = now;
|
||||
}, [items, paused]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
animationFrameRef.current = requestAnimationFrame(drawChart);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [drawChart]);
|
||||
|
||||
useEffect(() => {
|
||||
drawChart();
|
||||
}, [drawChart]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="bandwidth-chart-container">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
let nextCollectorId = 1;
|
||||
|
||||
function createScheduleId(): string {
|
||||
@ -1249,6 +1429,7 @@ export function App(): ReactElement {
|
||||
<nav className="tabs">
|
||||
<button className={tab === "collector" ? "tab active" : "tab"} onClick={() => setTab("collector")}>Linksammler</button>
|
||||
<button className={tab === "downloads" ? "tab active" : "tab"} onClick={() => setTab("downloads")}>Downloads</button>
|
||||
<button className={tab === "statistics" ? "tab active" : "tab"} onClick={() => setTab("statistics")}>Statistiken</button>
|
||||
<button className={tab === "settings" ? "tab active" : "tab"} onClick={() => setTab("settings")}>Einstellungen</button>
|
||||
</nav>
|
||||
|
||||
@ -1382,6 +1563,88 @@ export function App(): ReactElement {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "statistics" && (
|
||||
<section className="statistics-view">
|
||||
<article className="card stats-overview">
|
||||
<h3>Session-Ubersicht</h3>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Aktuelle Geschwindigkeit</span>
|
||||
<span className="stat-value">{snapshot.speedText.replace("Geschwindigkeit: ", "")}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Gesamt heruntergeladen</span>
|
||||
<span className="stat-value">{humanSize(snapshot.stats.totalDownloaded)}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Fertige Dateien</span>
|
||||
<span className="stat-value">{snapshot.stats.totalFiles}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Pakete</span>
|
||||
<span className="stat-value">{snapshot.stats.totalPackages}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Aktive Downloads</span>
|
||||
<span className="stat-value">{Object.values(snapshot.session.items).filter((item) => item.status === "downloading").length}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">In Warteschlange</span>
|
||||
<span className="stat-value">{Object.values(snapshot.session.items).filter((item) => item.status === "queued" || item.status === "reconnect_wait").length}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Fehlerhaft</span>
|
||||
<span className="stat-value danger">{Object.values(snapshot.session.items).filter((item) => item.status === "failed").length}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">{snapshot.etaText.split(": ")[0]}</span>
|
||||
<span className="stat-value">{snapshot.etaText.split(": ")[1] || "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="card stats-chart-card">
|
||||
<h3>Bandbreitenverlauf</h3>
|
||||
<BandwidthChart items={snapshot.session.items} running={snapshot.session.running} paused={snapshot.session.paused} />
|
||||
</article>
|
||||
|
||||
<article className="card stats-provider-card">
|
||||
<h3>Provider-Statistik</h3>
|
||||
<div className="provider-stats">
|
||||
{Object.entries(
|
||||
Object.values(snapshot.session.items).reduce((acc, item) => {
|
||||
const provider = item.provider || "unknown";
|
||||
if (!acc[provider]) {
|
||||
acc[provider] = { total: 0, completed: 0, failed: 0, bytes: 0 };
|
||||
}
|
||||
acc[provider].total += 1;
|
||||
if (item.status === "completed") acc[provider].completed += 1;
|
||||
if (item.status === "failed") acc[provider].failed += 1;
|
||||
acc[provider].bytes += item.downloadedBytes;
|
||||
return acc;
|
||||
}, {} as Record<string, { total: number; completed: number; failed: number; bytes: number }>)
|
||||
).map(([provider, stats]) => (
|
||||
<div key={provider} className="provider-stat-item">
|
||||
<span className="provider-name">{provider === "unknown" ? "Unbekannt" : providerLabels[provider as DebridProvider] || provider}</span>
|
||||
<div className="provider-bars">
|
||||
<div className="provider-bar">
|
||||
<div className="bar-fill completed" style={{ width: `${stats.total > 0 ? (stats.completed / stats.total) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="provider-detail">
|
||||
{stats.completed}/{stats.total} fertig | {humanSize(stats.bytes)}
|
||||
{stats.failed > 0 && <span className="danger"> | {stats.failed} Fehler</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(snapshot.session.items).length === 0 && (
|
||||
<div className="empty-provider">Noch keine Downloads vorhanden.</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "settings" && (
|
||||
<section className="settings-shell">
|
||||
<article className="card settings-toolbar">
|
||||
|
||||
@ -626,6 +626,156 @@ td {
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
}
|
||||
|
||||
.statistics-view {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.5fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.stats-overview {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
background: var(--field);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-value.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.stats-chart-card {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bandwidth-chart-container {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
background: var(--field);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bandwidth-chart-container canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stats-provider-card {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.provider-stats {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.provider-stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
background: var(--field);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.provider-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.provider-bar {
|
||||
height: 8px;
|
||||
background: var(--progress-track);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.bar-fill.completed {
|
||||
background: linear-gradient(90deg, #3bc9ff, #22d3ee);
|
||||
}
|
||||
|
||||
.provider-detail {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.empty-provider {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.statistics-view {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
}
|
||||
|
||||
.stats-overview {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px auto 56px auto 92px auto auto auto;
|
||||
|
||||
@ -25,5 +25,6 @@ export const IPC_CHANNELS = {
|
||||
PICK_CONTAINERS: "dialog:pick-containers",
|
||||
STATE_UPDATE: "state:update",
|
||||
CLIPBOARD_DETECTED: "clipboard:detected",
|
||||
TOGGLE_CLIPBOARD: "clipboard:toggle"
|
||||
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
||||
GET_SESSION_STATS: "stats:get-session-stats"
|
||||
} as const;
|
||||
|
||||
@ -2,6 +2,7 @@ import type {
|
||||
AddLinksPayload,
|
||||
AppSettings,
|
||||
DuplicatePolicy,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
UiSnapshot,
|
||||
@ -35,6 +36,7 @@ export interface ElectronApi {
|
||||
toggleClipboard: () => Promise<boolean>;
|
||||
pickFolder: () => Promise<string | null>;
|
||||
pickContainers: () => Promise<string[]>;
|
||||
getSessionStats: () => Promise<SessionStats>;
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||
|
||||
@ -226,3 +226,26 @@ export interface ParsedHashEntry {
|
||||
algorithm: "crc32" | "md5" | "sha1";
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export interface BandwidthSample {
|
||||
timestamp: number;
|
||||
speedBps: number;
|
||||
}
|
||||
|
||||
export interface BandwidthStats {
|
||||
samples: BandwidthSample[];
|
||||
currentSpeedBps: number;
|
||||
averageSpeedBps: number;
|
||||
maxSpeedBps: number;
|
||||
totalBytesSession: number;
|
||||
sessionDurationSeconds: number;
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
bandwidth: BandwidthStats;
|
||||
totalDownloads: number;
|
||||
completedDownloads: number;
|
||||
failedDownloads: number;
|
||||
activeDownloads: number;
|
||||
queuedDownloads: number;
|
||||
}
|
||||
|
||||
@ -954,6 +954,7 @@ describe("download manager", () => {
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
retryLimit: 1,
|
||||
autoExtract: false
|
||||
},
|
||||
emptySession(),
|
||||
@ -1084,6 +1085,7 @@ describe("download manager", () => {
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
retryLimit: 1,
|
||||
autoExtract: false
|
||||
},
|
||||
session,
|
||||
@ -1216,6 +1218,7 @@ describe("download manager", () => {
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
retryLimit: 1,
|
||||
autoExtract: false
|
||||
},
|
||||
session,
|
||||
@ -1359,6 +1362,7 @@ describe("download manager", () => {
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
retryLimit: 1,
|
||||
autoExtract: false
|
||||
},
|
||||
session,
|
||||
@ -1494,7 +1498,7 @@ describe("download manager", () => {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
}, 30000);
|
||||
}, 70000);
|
||||
|
||||
it("retries non-retriable HTTP statuses and eventually succeeds", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
@ -4083,6 +4087,7 @@ describe("download manager", () => {
|
||||
const sourceFileName = `${nestedFolder}/tvr-gotham-s03e11-720p.mkv`;
|
||||
const zip = new AdmZip();
|
||||
zip.addFile(sourceFileName, Buffer.from("video"));
|
||||
zip.addFile(`${nestedFolder}/tvr-gotham-s03-720p.nfo`, Buffer.from("info"));
|
||||
zip.addFile(`${nestedFolder}/Thumbs.db`, Buffer.from("thumbs"));
|
||||
zip.addFile("desktop.ini", Buffer.from("system"));
|
||||
const archivePath = path.join(outputDir, "episode.zip");
|
||||
|
||||
@ -362,6 +362,73 @@ describe("update", () => {
|
||||
expect(requestedUrls.some((url) => url.includes("latest.yml"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects installer when latest.yml SHA512 digest does not match", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const wrongDigestBase64 = Buffer.alloc(64, 0x13).toString("base64");
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.endsWith("/releases/tags/v9.9.9")) {
|
||||
return new Response(JSON.stringify({
|
||||
tag_name: "v9.9.9",
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Real-Debrid-Downloader Setup 9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/setup-no-digest.exe"
|
||||
},
|
||||
{
|
||||
name: "latest.yml",
|
||||
browser_download_url: "https://example.invalid/latest.yml"
|
||||
}
|
||||
]
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("latest.yml")) {
|
||||
return new Response(
|
||||
`version: 9.9.9\npath: Real-Debrid-Downloader Setup 9.9.9.exe\nsha512: ${wrongDigestBase64}\n`,
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/yaml" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes("setup-no-digest.exe")) {
|
||||
return new Response(executablePayload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(executablePayload.length)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("missing", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const prechecked: UpdateCheckResult = {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
|
||||
setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe",
|
||||
setupAssetDigest: ""
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
expect(result.started).toBe(false);
|
||||
expect(result.message).toMatch(/sha512|integrit|mismatch/i);
|
||||
});
|
||||
|
||||
it("emits install progress events while downloading and launching update", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const digest = sha256Hex(executablePayload);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user