Implement full UX upgrade and Rapidgator filename hardening in v1.3.2

This commit is contained in:
Sucukdeluxe
2026-02-27 14:20:54 +01:00
parent 7381e54f4f
commit 447dd7feff
15 changed files with 1597 additions and 372 deletions
+24
View File
@@ -126,6 +126,30 @@ export class AppController {
this.manager.cancelPackage(packageId);
}
public renamePackage(packageId: string, newName: string): void {
this.manager.renamePackage(packageId, newName);
}
public reorderPackages(packageIds: string[]): void {
this.manager.reorderPackages(packageIds);
}
public removeItem(itemId: string): void {
this.manager.removeItem(itemId);
}
public togglePackage(packageId: string): void {
this.manager.togglePackage(packageId);
}
public exportQueue(): string {
return this.manager.exportQueue();
}
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
return this.manager.importQueue(json);
}
public shutdown(): void {
this.manager.stop();
this.megaWebFallback.dispose();
+5 -1
View File
@@ -58,6 +58,10 @@ export function defaultSettings(): AppSettings {
speedLimitKbps: 0,
speedLimitMode: "global",
updateRepo: DEFAULT_UPDATE_REPO,
autoUpdateCheck: true
autoUpdateCheck: true,
clipboardWatch: false,
minimizeToTray: false,
theme: "dark" as const,
bandwidthSchedules: []
};
}
+110 -25
View File
@@ -141,6 +141,71 @@ function decodeHtmlEntities(text: string): string {
.replace(/>/g, ">");
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function looksLikeFileName(value: string): boolean {
return /\.(?:part\d+\.rar|r\d{2}|rar|zip|7z|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|m2ts|ts|webm|mp3|flac|aac|srt|ass|sub)$/i.test(value);
}
function normalizeResolvedFilename(value: string): string {
const candidate = decodeHtmlEntities(String(value || ""))
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.replace(/^['"]+|['"]+$/g, "")
.replace(/^download\s+file\s+/i, "")
.replace(/\s*[-|]\s*rapidgator.*$/i, "")
.trim();
if (!candidate || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) {
return "";
}
return candidate;
}
function filenameFromRapidgatorUrlPath(link: string): string {
try {
const parsed = new URL(link);
const pathParts = parsed.pathname.split("/").filter(Boolean);
for (let index = pathParts.length - 1; index >= 0; index -= 1) {
const raw = safeDecode(pathParts[index]).replace(/\.html?$/i, "").trim();
const normalized = normalizeResolvedFilename(raw);
if (normalized) {
return normalized;
}
}
return "";
} catch {
return "";
}
}
function extractRapidgatorFilenameFromHtml(html: string): string {
const patterns = [
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+name=["']title["'][^>]+content=["']([^"']+)["']/i,
/<title>([^<]+)<\/title>/i,
/(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]+)\s*</i,
/(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]+)/i,
/download\s+file\s+([^<\r\n]+)/i,
/([A-Za-z0-9][A-Za-z0-9._\-()[\] ]{2,220}\.(?:part\d+\.rar|r\d{2}|rar|zip|7z|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|m2ts|ts|webm|mp3|flac|aac|srt|ass|sub))/i
];
for (const pattern of patterns) {
const match = html.match(pattern);
const normalized = normalizeResolvedFilename(match?.[1] || "");
if (normalized) {
return normalized;
}
}
return "";
}
async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
if (items.length === 0) {
return;
@@ -161,31 +226,43 @@ async function resolveRapidgatorFilename(link: string): Promise<string> {
if (!isRapidgatorLink(link)) {
return "";
}
try {
const response = await fetch(link, {
method: "GET",
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
}
});
if (!response.ok) {
return "";
}
const html = await response.text();
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
const title = decodeHtmlEntities((titleMatch?.[1] || "").trim());
if (!title) {
return "";
}
const preferred = title.match(/download\s+file\s+(.+)$/i)?.[1]?.trim() || title;
if (!preferred) {
return "";
}
const withoutSuffix = preferred.replace(/\s*-\s*rapidgator.*$/i, "").trim();
return withoutSuffix;
} catch {
return "";
const fromUrl = filenameFromRapidgatorUrlPath(link);
if (fromUrl) {
return fromUrl;
}
for (let attempt = 1; attempt <= REQUEST_RETRIES + 2; attempt += 1) {
try {
const response = await fetch(link, {
method: "GET",
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,de;q=0.8"
}
});
if (!response.ok) {
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
await sleep(retryDelay(attempt));
continue;
}
return "";
}
const html = await response.text();
const fromHtml = extractRapidgatorFilenameFromHtml(html);
if (fromHtml) {
return fromHtml;
}
} catch {
// retry below
}
if (attempt < REQUEST_RETRIES + 2) {
await sleep(retryDelay(attempt));
}
}
return "";
}
function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] {
@@ -492,7 +569,7 @@ export class DebridService {
}
const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link));
await runWithConcurrency(remaining, 6, async (link) => {
await runWithConcurrency(remaining, 3, async (link) => {
const fromPage = await resolveRapidgatorFilename(link);
if (fromPage && !looksLikeOpaqueFilename(fromPage)) {
clean.set(link, fromPage);
@@ -523,8 +600,16 @@ export class DebridService {
try {
const result = await this.unrestrictViaProvider(provider, link);
let fileName = result.fileName;
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
const fromPage = await resolveRapidgatorFilename(link);
if (fromPage) {
fileName = fromPage;
}
}
return {
...result,
fileName,
provider,
providerLabel: PROVIDER_LABELS[provider]
};
+287 -13
View File
@@ -3,8 +3,8 @@ import path from "node:path";
import os from "node:os";
import { EventEmitter } from "node:events";
import { v4 as uuidv4 } from "uuid";
import { AppSettings, DownloadItem, DownloadSummary, DownloadStatus, PackageEntry, ParsedPackageInput, SessionState, UiSnapshot } from "../shared/types";
import { CHUNK_SIZE, REQUEST_RETRIES } from "./constants";
import { AppSettings, DownloadItem, DownloadStats, DownloadSummary, DownloadStatus, PackageEntry, ParsedPackageInput, SessionState, UiSnapshot } from "../shared/types";
import { REQUEST_RETRIES } from "./constants";
import { cleanupCancelledPackageArtifactsAsync } from "./cleanup";
import { DebridService, MegaWebUnrestrictor } from "./debrid";
import { extractPackageArchives } from "./extractor";
@@ -17,7 +17,7 @@ type ActiveTask = {
itemId: string;
packageId: string;
abortController: AbortController;
abortReason: "stop" | "cancel" | "reconnect" | "none";
abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "none";
resumable: boolean;
speedEvents: Array<{ at: number; bytes: number }>;
nonResumableCounted: boolean;
@@ -43,6 +43,35 @@ function parseContentRangeTotal(contentRange: string | null): number | null {
return Number.isFinite(value) ? value : null;
}
function parseContentDispositionFilename(contentDisposition: string | null): string {
if (!contentDisposition) {
return "";
}
const encodedMatch = contentDisposition.match(/filename\*\s*=\s*([^;]+)/i);
if (encodedMatch?.[1]) {
let value = encodedMatch[1].trim();
value = value.replace(/^UTF-8''/i, "");
value = value.replace(/^['"]+|['"]+$/g, "");
try {
const decoded = decodeURIComponent(value).trim();
if (decoded) {
return decoded;
}
} catch {
if (value) {
return value;
}
}
}
const plainMatch = contentDisposition.match(/filename\s*=\s*([^;]+)/i);
if (!plainMatch?.[1]) {
return "";
}
return plainMatch[1].trim().replace(/^['"]+|['"]+$/g, "");
}
function canRetryStatus(status: number): boolean {
return status === 429 || status >= 500;
}
@@ -186,18 +215,189 @@ export class DownloadManager extends EventEmitter {
const remaining = totalItems - doneItems;
const eta = remaining > 0 && rate > 0 ? remaining / rate : -1;
const reconnectMs = Math.max(0, this.session.reconnectUntil - now);
return {
settings: this.settings,
session: this.getSession(),
summary: this.summary,
stats: this.getStats(),
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
etaText: paused ? "ETA: --" : `ETA: ${formatEta(eta)}`,
canStart: !this.session.running,
canStop: this.session.running,
canPause: this.session.running
canPause: this.session.running,
clipboardActive: this.settings.clipboardWatch,
reconnectSeconds: Math.ceil(reconnectMs / 1000)
};
}
public getStats(): DownloadStats {
let totalDownloaded = 0;
let totalFiles = 0;
for (const item of Object.values(this.session.items)) {
if (item.status === "completed") {
totalDownloaded += item.downloadedBytes;
totalFiles += 1;
}
}
if (this.session.running) {
let visibleRunBytes = 0;
for (const itemId of this.runItemIds) {
const item = this.session.items[itemId];
if (item) {
visibleRunBytes += item.downloadedBytes;
}
}
totalDownloaded += Math.max(0, this.session.totalDownloadedBytes - visibleRunBytes);
} else {
totalDownloaded = Math.max(totalDownloaded, this.session.totalDownloadedBytes);
}
return {
totalDownloaded,
totalFiles,
totalPackages: Object.keys(this.session.packages).length,
sessionStartedAt: this.session.runStartedAt
};
}
public renamePackage(packageId: string, newName: string): void {
const pkg = this.session.packages[packageId];
if (!pkg) {
return;
}
pkg.name = sanitizeFilename(newName) || pkg.name;
pkg.updatedAt = nowMs();
this.persistSoon();
this.emitState(true);
}
public reorderPackages(packageIds: string[]): void {
const valid = packageIds.filter((id) => this.session.packages[id]);
const remaining = this.session.packageOrder.filter((id) => !valid.includes(id));
this.session.packageOrder = [...valid, ...remaining];
this.persistSoon();
this.emitState(true);
}
public removeItem(itemId: string): void {
const item = this.session.items[itemId];
if (!item) {
return;
}
this.recordRunOutcome(itemId, "cancelled");
const active = this.activeTasks.get(itemId);
if (active) {
active.abortReason = "cancel";
active.abortController.abort("cancel");
}
const pkg = this.session.packages[item.packageId];
if (pkg) {
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
if (pkg.itemIds.length === 0) {
this.removePackageFromSession(item.packageId, []);
} else {
pkg.updatedAt = nowMs();
}
}
delete this.session.items[itemId];
this.releaseTargetPath(itemId);
this.persistSoon();
this.emitState(true);
}
public togglePackage(packageId: string): void {
const pkg = this.session.packages[packageId];
if (!pkg) {
return;
}
const nextEnabled = !pkg.enabled;
pkg.enabled = nextEnabled;
if (!nextEnabled) {
if (pkg.status === "downloading") {
pkg.status = "paused";
}
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item) {
continue;
}
if (this.session.running && !isFinishedStatus(item.status) && !this.runOutcomes.has(itemId)) {
this.runItemIds.delete(itemId);
}
const active = this.activeTasks.get(itemId);
if (active) {
active.abortReason = "package_toggle";
active.abortController.abort("package_toggle");
continue;
}
if (item.status === "queued" || item.status === "reconnect_wait") {
item.status = "queued";
item.speedBps = 0;
item.fullStatus = "Paket gestoppt";
item.updatedAt = nowMs();
}
}
this.runPackageIds.delete(packageId);
this.runCompletedPackages.delete(packageId);
} else {
if (pkg.status === "paused") {
pkg.status = "queued";
}
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item) {
continue;
}
if (item.status === "queued" && item.fullStatus === "Paket gestoppt") {
item.fullStatus = "Wartet";
item.updatedAt = nowMs();
}
}
if (this.session.running) {
void this.ensureScheduler();
}
}
pkg.updatedAt = nowMs();
this.persistSoon();
this.emitState(true);
}
public exportQueue(): string {
const exportData = {
version: 1,
packages: this.session.packageOrder.map((id) => {
const pkg = this.session.packages[id];
if (!pkg) {
return null;
}
return {
name: pkg.name,
links: pkg.itemIds
.map((itemId) => this.session.items[itemId]?.url)
.filter(Boolean)
};
}).filter(Boolean)
};
return JSON.stringify(exportData, null, 2);
}
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
const data = JSON.parse(json) as { packages?: Array<{ name: string; links: string[] }> };
if (!Array.isArray(data.packages)) {
return { addedPackages: 0, addedLinks: 0 };
}
const inputs: ParsedPackageInput[] = data.packages
.filter((pkg) => pkg.name && Array.isArray(pkg.links) && pkg.links.length > 0)
.map((pkg) => ({ name: pkg.name, links: pkg.links }));
return this.addPackages(inputs);
}
public clearAll(): void {
this.stop();
this.session.packageOrder = [];
@@ -238,6 +438,7 @@ export class DownloadManager extends EventEmitter {
status: "queued",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: nowMs(),
updatedAt: nowMs()
};
@@ -378,7 +579,13 @@ export class DownloadManager extends EventEmitter {
return;
}
const runItems = Object.values(this.session.items)
.filter((item) => item.status === "queued" || item.status === "reconnect_wait");
.filter((item) => {
if (item.status !== "queued" && item.status !== "reconnect_wait") {
return false;
}
const pkg = this.session.packages[item.packageId];
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
});
if (runItems.length === 0) {
this.runItemIds.clear();
this.runPackageIds.clear();
@@ -464,6 +671,9 @@ export class DownloadManager extends EventEmitter {
}
}
for (const pkg of Object.values(this.session.packages)) {
if (pkg.enabled === undefined) {
pkg.enabled = true;
}
if (pkg.status === "downloading"
|| pkg.status === "validating"
|| pkg.status === "extracting"
@@ -749,6 +959,10 @@ export class DownloadManager extends EventEmitter {
private markQueuedAsReconnectWait(): void {
for (const item of Object.values(this.session.items)) {
const pkg = this.session.packages[item.packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) {
continue;
}
if (item.status === "queued") {
item.status = "reconnect_wait";
item.fullStatus = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`;
@@ -761,7 +975,7 @@ export class DownloadManager extends EventEmitter {
private findNextQueuedItem(): { packageId: string; itemId: string } | null {
for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled) {
if (!pkg || pkg.cancelled || !pkg.enabled) {
continue;
}
for (const itemId of pkg.itemIds) {
@@ -778,13 +992,28 @@ export class DownloadManager extends EventEmitter {
}
private hasQueuedItems(): boolean {
return Object.values(this.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) {
continue;
}
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item) {
continue;
}
if (item.status === "queued" || item.status === "reconnect_wait") {
return true;
}
}
}
return false;
}
private startItem(packageId: string, itemId: string): void {
const item = this.session.items[itemId];
const pkg = this.session.packages[packageId];
if (!item || !pkg || pkg.cancelled) {
if (!item || !pkg || pkg.cancelled || !pkg.enabled) {
return;
}
@@ -927,6 +1156,10 @@ export class DownloadManager extends EventEmitter {
} else if (reason === "reconnect") {
item.status = "queued";
item.fullStatus = "Wartet auf Reconnect";
} else if (reason === "package_toggle") {
item.status = "queued";
item.speedBps = 0;
item.fullStatus = "Paket gestoppt";
} else {
const errorText = compactErrorText(error);
const shouldFreshRetry = !freshRetryUsed && isFetchFailure(errorText);
@@ -978,8 +1211,9 @@ export class DownloadManager extends EventEmitter {
}
let lastError = "";
let effectiveTargetPath = targetPath;
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
const existingBytes = fs.existsSync(targetPath) ? fs.statSync(targetPath).size : 0;
const existingBytes = fs.existsSync(effectiveTargetPath) ? fs.statSync(effectiveTargetPath).size : 0;
const headers: Record<string, string> = {};
if (existingBytes > 0) {
headers.Range = `bytes=${existingBytes}-`;
@@ -1042,6 +1276,22 @@ export class DownloadManager extends EventEmitter {
const acceptRanges = (response.headers.get("accept-ranges") || "").toLowerCase().includes("bytes");
try {
if (existingBytes === 0) {
const rawHeaderName = parseContentDispositionFilename(response.headers.get("content-disposition")).trim();
const fromHeader = rawHeaderName ? sanitizeFilename(rawHeaderName) : "";
if (fromHeader && !looksLikeOpaqueFilename(fromHeader) && fromHeader !== item.fileName) {
const pkg = this.session.packages[item.packageId];
if (pkg) {
this.releaseTargetPath(item.id);
effectiveTargetPath = this.claimTargetPath(item.id, path.join(pkg.outputDir, fromHeader));
item.fileName = fromHeader;
item.targetPath = effectiveTargetPath;
item.updatedAt = nowMs();
this.emitState();
}
}
}
const resumable = response.status === 206 || acceptRanges;
active.resumable = resumable;
@@ -1057,10 +1307,10 @@ export class DownloadManager extends EventEmitter {
const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w";
if (writeMode === "w" && existingBytes > 0) {
fs.rmSync(targetPath, { force: true });
fs.rmSync(effectiveTargetPath, { force: true });
}
const stream = fs.createWriteStream(targetPath, { flags: writeMode });
const stream = fs.createWriteStream(effectiveTargetPath, { flags: writeMode });
let written = writeMode === "a" ? existingBytes : 0;
let windowBytes = 0;
let windowStarted = nowMs();
@@ -1183,11 +1433,35 @@ export class DownloadManager extends EventEmitter {
throw new Error(lastError || "Download fehlgeschlagen");
}
private getEffectiveSpeedLimitKbps(): number {
const schedules = this.settings.bandwidthSchedules;
if (schedules.length > 0) {
const hour = new Date().getHours();
for (const entry of schedules) {
if (!entry.enabled) {
continue;
}
const wraps = entry.startHour > entry.endHour;
const inRange = wraps
? hour >= entry.startHour || hour < entry.endHour
: hour >= entry.startHour && hour < entry.endHour;
if (inRange) {
return entry.speedLimitKbps;
}
}
}
if (this.settings.speedLimitEnabled && this.settings.speedLimitKbps > 0) {
return this.settings.speedLimitKbps;
}
return 0;
}
private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise<void> {
if (!this.settings.speedLimitEnabled || this.settings.speedLimitKbps <= 0) {
const limitKbps = this.getEffectiveSpeedLimitKbps();
if (limitKbps <= 0) {
return;
}
const bytesPerSecond = this.settings.speedLimitKbps * 1024;
const bytesPerSecond = limitKbps * 1024;
const now = nowMs();
const elapsed = Math.max((now - localWindowStarted) / 1000, 0.1);
if (this.settings.speedLimitMode === "per_download") {
+116 -2
View File
@@ -1,5 +1,5 @@
import path from "node:path";
import { app, BrowserWindow, dialog, ipcMain, IpcMainInvokeEvent, shell } from "electron";
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
import { AddLinksPayload, AppSettings } from "../shared/types";
import { AppController } from "./app-controller";
import { IPC_CHANNELS } from "../shared/ipc";
@@ -7,6 +7,9 @@ import { logger } from "./logger";
import { APP_NAME } from "./constants";
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
let lastClipboardText = "";
const controller = new AppController();
function isDevMode(): boolean {
@@ -37,6 +40,87 @@ function createWindow(): BrowserWindow {
return window;
}
function createTray(): void {
if (tray) {
return;
}
const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
try {
tray = new Tray(iconPath);
} catch {
return;
}
tray.setToolTip(APP_NAME);
const contextMenu = Menu.buildFromTemplate([
{ label: "Anzeigen", click: () => { mainWindow?.show(); mainWindow?.focus(); } },
{ type: "separator" },
{ label: "Start", click: () => { controller.start(); } },
{ label: "Stop", click: () => { controller.stop(); } },
{ type: "separator" },
{ label: "Beenden", click: () => { app.quit(); } }
]);
tray.setContextMenu(contextMenu);
tray.on("double-click", () => {
mainWindow?.show();
mainWindow?.focus();
});
}
function destroyTray(): void {
if (tray) {
tray.destroy();
tray = null;
}
}
function extractLinksFromText(text: string): string[] {
const matches = text.match(/https?:\/\/[^\s<>"']+/gi);
return matches ? Array.from(new Set(matches)) : [];
}
function startClipboardWatcher(): void {
if (clipboardTimer) {
return;
}
lastClipboardText = clipboard.readText();
clipboardTimer = setInterval(() => {
const text = clipboard.readText();
if (text === lastClipboardText || !text.trim()) {
return;
}
lastClipboardText = text;
const links = extractLinksFromText(text);
if (links.length > 0 && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IPC_CHANNELS.CLIPBOARD_DETECTED, links);
}
}, 2000);
}
function stopClipboardWatcher(): void {
if (clipboardTimer) {
clearInterval(clipboardTimer);
clipboardTimer = null;
}
}
function updateClipboardWatcher(): void {
const settings = controller.getSettings();
if (settings.clipboardWatch) {
startClipboardWatcher();
} else {
stopClipboardWatcher();
}
}
function updateTray(): void {
const settings = controller.getSettings();
if (settings.minimizeToTray) {
createTray();
} else {
destroyTray();
}
}
function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.GET_SNAPSHOT, () => controller.getSnapshot());
ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
@@ -62,7 +146,12 @@ function registerIpcHandlers(): void {
return false;
}
});
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => controller.updateSettings(partial ?? {}));
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
const result = controller.updateSettings(partial ?? {});
updateClipboardWatcher();
updateTray();
return result;
});
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => controller.addLinks(payload));
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => controller.addContainers(filePaths ?? []));
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
@@ -70,6 +159,19 @@ function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop());
ipcMain.handle(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause());
ipcMain.handle(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => controller.cancelPackage(packageId));
ipcMain.handle(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => controller.renamePackage(packageId, newName));
ipcMain.handle(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => controller.reorderPackages(packageIds));
ipcMain.handle(IPC_CHANNELS.REMOVE_ITEM, (_event: IpcMainInvokeEvent, itemId: string) => controller.removeItem(itemId));
ipcMain.handle(IPC_CHANNELS.TOGGLE_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => controller.togglePackage(packageId));
ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, () => controller.exportQueue());
ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => controller.importQueue(json));
ipcMain.handle(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => {
const settings = controller.getSettings();
const next = !settings.clipboardWatch;
controller.updateSettings({ clipboardWatch: next });
updateClipboardWatcher();
return next;
});
ipcMain.handle(IPC_CHANNELS.PICK_FOLDER, async () => {
const options = {
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
@@ -100,6 +202,16 @@ function registerIpcHandlers(): void {
app.whenReady().then(() => {
registerIpcHandlers();
mainWindow = createWindow();
updateClipboardWatcher();
updateTray();
mainWindow.on("close", (event) => {
const settings = controller.getSettings();
if (settings.minimizeToTray && tray) {
event.preventDefault();
mainWindow?.hide();
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
@@ -115,6 +227,8 @@ app.on("window-all-closed", () => {
});
app.on("before-quit", () => {
stopClipboardWatcher();
destroyTray();
try {
controller.shutdown();
} catch (error) {
+28 -2
View File
@@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { AppSettings, SessionState } from "../shared/types";
import { AppSettings, BandwidthScheduleEntry, SessionState } from "../shared/types";
import { defaultSettings } from "./constants";
import { logger } from "./logger";
@@ -10,6 +10,7 @@ const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]);
const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]);
const VALID_SPEED_MODES = new Set(["global", "per_download"]);
const VALID_THEMES = new Set(["dark", "light"]);
function asText(value: unknown): string {
return String(value ?? "").trim();
@@ -23,6 +24,27 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number)
return Math.max(min, Math.min(max, Math.floor(num)));
}
function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] {
if (!Array.isArray(raw)) {
return [];
}
const normalized: BandwidthScheduleEntry[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") {
continue;
}
const value = entry as Partial<BandwidthScheduleEntry>;
normalized.push({
startHour: clampNumber(value.startHour, 0, 0, 23),
endHour: clampNumber(value.endHour, 8, 0, 23),
speedLimitKbps: clampNumber(value.speedLimitKbps, 0, 0, 500000),
enabled: value.enabled === undefined ? true : Boolean(value.enabled)
});
}
return normalized;
}
export function normalizeSettings(settings: AppSettings): AppSettings {
const defaults = defaultSettings();
const normalized: AppSettings = {
@@ -51,7 +73,11 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000),
reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600),
autoUpdateCheck: Boolean(settings.autoUpdateCheck),
updateRepo: asText(settings.updateRepo) || defaults.updateRepo
updateRepo: asText(settings.updateRepo) || defaults.updateRepo,
clipboardWatch: Boolean(settings.clipboardWatch),
minimizeToTray: Boolean(settings.minimizeToTray),
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules)
};
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {