Persist collector state and stabilize download controls
Persist collector packages and disclosure state through validated atomic AppData storage with backup recovery and shutdown synchronization. Align download sidebar metrics with the hidden-extracted presentation scope and keep Start available through temporary Real-Debrid cooldowns while preserving hard account blocks.
This commit is contained in:
@@ -76,7 +76,9 @@ import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log
|
||||
import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log";
|
||||
import { buildAccountSummary, buildNotificationSupportPayload, diffAccountSummary, type NotificationSupportPayload } from "./support-data";
|
||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||||
import { CollectorStore } from "./collector-store";
|
||||
import type { CollectorPersistenceState } from "../shared/collector";
|
||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||||
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
||||
import { overlayLiveUsageCounters } from "./settings-live-overlay";
|
||||
@@ -126,6 +128,8 @@ export class AppController {
|
||||
|
||||
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
|
||||
|
||||
private collectorStore = new CollectorStore(this.storagePaths.collectorFile);
|
||||
|
||||
private notificationOutbox: NotificationOutbox;
|
||||
|
||||
private downloadHealthMonitor: DownloadHealthMonitor;
|
||||
@@ -997,6 +1001,15 @@ export class AppController {
|
||||
return prepareCollectorText(request);
|
||||
}
|
||||
|
||||
public getCollectorState(): CollectorPersistenceState {
|
||||
return this.collectorStore.getState();
|
||||
}
|
||||
|
||||
public saveCollectorState(state: CollectorPersistenceState): CollectorPersistenceState {
|
||||
this.collectorStore.update(state);
|
||||
return this.collectorStore.getState();
|
||||
}
|
||||
|
||||
public prepareCollectorContainers(filePaths: string[], addedAt: number): Promise<CollectorInspectionResult> {
|
||||
return prepareCollectorContainers(filePaths, addedAt);
|
||||
}
|
||||
@@ -1388,6 +1401,11 @@ export class AppController {
|
||||
}
|
||||
stopDebugServer();
|
||||
abortActiveUpdateDownload();
|
||||
try {
|
||||
this.collectorStore?.flushSync();
|
||||
} catch (error) {
|
||||
logger.warn(`Linksammler konnte beim Beenden nicht gespeichert werden: ${String(error)}`);
|
||||
}
|
||||
cancelPendingAsyncSaves();
|
||||
this.manager.prepareForShutdown();
|
||||
if (this.downloadHealthEvaluation) {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
validateCollectorPersistenceState,
|
||||
type CollectorPersistenceState
|
||||
} from "../shared/collector";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const maxPersistenceBytes = 64 * 1024 * 1024;
|
||||
const writeDelayMs = 300;
|
||||
const renameRetryDelaysMs = [15, 40, 90];
|
||||
|
||||
interface CollectorPersistenceFile extends CollectorPersistenceState {
|
||||
version: 1;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function emptyState(): CollectorPersistenceState {
|
||||
return { packages: [], collapsedPackageIds: [] };
|
||||
}
|
||||
|
||||
function cloneState(state: CollectorPersistenceState): CollectorPersistenceState {
|
||||
return structuredClone(state);
|
||||
}
|
||||
|
||||
function renameErrorCode(error: unknown): string {
|
||||
return error && typeof error === "object" && "code" in error
|
||||
? String((error as NodeJS.ErrnoException).code || "")
|
||||
: "";
|
||||
}
|
||||
|
||||
function isTransientRenameError(error: unknown): boolean {
|
||||
return ["EPERM", "EACCES", "EBUSY"].includes(renameErrorCode(error));
|
||||
}
|
||||
|
||||
function renameSyncWithRetry(tempPath: string, filePath: string): void {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
fs.renameSync(tempPath, filePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isTransientRenameError(error) || attempt >= renameRetryDelaysMs.length) throw error;
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, renameRetryDelaysMs[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renameWithRetry(tempPath: string, filePath: string): Promise<void> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
await fsp.rename(tempPath, filePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isTransientRenameError(error) || attempt >= renameRetryDelaysMs.length) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, renameRetryDelaysMs[attempt]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parsePersistenceFile(filePath: string): CollectorPersistenceFile | null {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile() || stat.size > maxPersistenceBytes) return null;
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
if (Object.keys(parsed).some((key) => !["version", "packages", "collapsedPackageIds", "updatedAt"].includes(key))) return null;
|
||||
if (parsed.version !== 1 || typeof parsed.updatedAt !== "number" || !Number.isSafeInteger(parsed.updatedAt) || parsed.updatedAt < 0) return null;
|
||||
const state = validateCollectorPersistenceState({
|
||||
packages: parsed.packages,
|
||||
collapsedPackageIds: parsed.collapsedPackageIds
|
||||
});
|
||||
return { version: 1, updatedAt: parsed.updatedAt, ...state };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeState(state: CollectorPersistenceState, updatedAt: number): string {
|
||||
const payload: CollectorPersistenceFile = {
|
||||
version: 1,
|
||||
packages: state.packages,
|
||||
collapsedPackageIds: state.collapsedPackageIds,
|
||||
updatedAt
|
||||
};
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function atomicWriteSync(filePath: string, payload: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
let descriptor: number | null = null;
|
||||
try {
|
||||
descriptor = fs.openSync(tempPath, "w");
|
||||
fs.writeFileSync(descriptor, payload, "utf8");
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = null;
|
||||
renameSyncWithRetry(tempPath, filePath);
|
||||
} catch (error) {
|
||||
if (descriptor !== null) {
|
||||
try { fs.closeSync(descriptor); } catch {}
|
||||
}
|
||||
try { fs.rmSync(tempPath, { force: true }); } catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicWrite(filePath: string, payload: string, isCurrent: () => boolean): Promise<boolean> {
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
let handle: Awaited<ReturnType<typeof fsp.open>> | null = null;
|
||||
try {
|
||||
handle = await fsp.open(tempPath, "w");
|
||||
await handle.writeFile(payload, "utf8");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = null;
|
||||
if (!isCurrent()) {
|
||||
await fsp.rm(tempPath, { force: true });
|
||||
return false;
|
||||
}
|
||||
await renameWithRetry(tempPath, filePath);
|
||||
return isCurrent();
|
||||
} catch (error) {
|
||||
if (handle) await handle.close().catch(() => {});
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class CollectorStore {
|
||||
private state: CollectorPersistenceState;
|
||||
private updatedAt = 0;
|
||||
private revision = 0;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private activeWrite: Promise<void> | null = null;
|
||||
private retryAttempt = 0;
|
||||
|
||||
public constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly writeFile: typeof atomicWrite = atomicWrite
|
||||
) {
|
||||
const primary = parsePersistenceFile(filePath);
|
||||
const backup = parsePersistenceFile(`${filePath}.bak`);
|
||||
const loaded = primary && (!backup || primary.updatedAt >= backup.updatedAt) ? primary : backup;
|
||||
this.state = loaded
|
||||
? cloneState({ packages: loaded.packages, collapsedPackageIds: loaded.collapsedPackageIds })
|
||||
: emptyState();
|
||||
this.updatedAt = loaded?.updatedAt || 0;
|
||||
if (loaded === backup && backup && (!primary || backup.updatedAt > primary.updatedAt)) {
|
||||
try {
|
||||
this.writeSync();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
public getState(): CollectorPersistenceState {
|
||||
return cloneState(this.state);
|
||||
}
|
||||
|
||||
public update(state: CollectorPersistenceState): void {
|
||||
const nextState = validateCollectorPersistenceState(state);
|
||||
const nextUpdatedAt = Math.max(Date.now(), this.updatedAt + 1);
|
||||
if (Buffer.byteLength(serializeState(nextState, nextUpdatedAt), "utf8") > maxPersistenceBytes) {
|
||||
throw new Error("Linksammler-Speicherzustand ist zu groß");
|
||||
}
|
||||
this.state = nextState;
|
||||
this.revision += 1;
|
||||
this.updatedAt = nextUpdatedAt;
|
||||
this.scheduleWrite(this.retryAttempt > 0 ? this.retryDelayMs() : writeDelayMs);
|
||||
}
|
||||
|
||||
public flushSync(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.writeSync();
|
||||
}
|
||||
|
||||
private retryDelayMs(): number {
|
||||
return Math.min(60_000, 1_000 * 2 ** Math.max(0, this.retryAttempt - 1));
|
||||
}
|
||||
|
||||
private scheduleWrite(delayMs = writeDelayMs): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.enqueueWrite();
|
||||
}, delayMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
private enqueueWrite(): void {
|
||||
const revision = this.revision;
|
||||
const state = cloneState(this.state);
|
||||
const updatedAt = this.updatedAt;
|
||||
const preceding = this.activeWrite || Promise.resolve();
|
||||
const write = preceding.catch(() => {}).then(async () => {
|
||||
const payload = serializeState(state, updatedAt);
|
||||
const isCurrent = () => revision === this.revision;
|
||||
const backupCurrent = await this.writeFile(`${this.filePath}.bak`, payload, isCurrent);
|
||||
if (!backupCurrent) return;
|
||||
const primaryCurrent = await this.writeFile(this.filePath, payload, isCurrent);
|
||||
if (!primaryCurrent) this.writeSync();
|
||||
else this.retryAttempt = 0;
|
||||
}).catch((error) => {
|
||||
if (revision === this.revision) {
|
||||
this.retryAttempt += 1;
|
||||
const retryMs = this.retryDelayMs();
|
||||
logger.warn(`Linksammler-Speicherung fehlgeschlagen, neuer Versuch in ${retryMs} ms: ${String(error)}`);
|
||||
this.scheduleWrite(retryMs);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (this.activeWrite === write) this.activeWrite = null;
|
||||
if (revision !== this.revision && !this.timer) {
|
||||
try {
|
||||
this.writeSync();
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
this.activeWrite = write;
|
||||
}
|
||||
|
||||
private writeSync(): void {
|
||||
const payload = serializeState(this.state, this.updatedAt || Date.now());
|
||||
atomicWriteSync(`${this.filePath}.bak`, payload);
|
||||
atomicWriteSync(this.filePath, payload);
|
||||
}
|
||||
}
|
||||
@@ -773,6 +773,11 @@ function getRealDebridAccountCooldown(accountId: string, now = Date.now()): Real
|
||||
return detail;
|
||||
}
|
||||
|
||||
export function isRealDebridAccountBlockedForStart(accountId: string, now = Date.now()): boolean {
|
||||
const cooldown = getRealDebridAccountCooldown(accountId, now);
|
||||
return Boolean(cooldown && cooldown.category !== "temporary");
|
||||
}
|
||||
|
||||
export function getAvailableRealDebridAccounts(settings: AppSettings, now = Date.now()): RealDebridAccountEntry[] {
|
||||
return getConfiguredRealDebridAccounts(settings).filter((account) => account.enabled
|
||||
&& !isRealDebridAccountDailyLimitReached(settings, account.id, now)
|
||||
|
||||
@@ -39,8 +39,9 @@ import {
|
||||
addProviderTotalUsageBytes,
|
||||
addRealDebridAccountDailyUsageBytes,
|
||||
addRealDebridAccountTotalUsageBytes,
|
||||
getProviderUsageDayKey,
|
||||
isProviderDailyLimitReached
|
||||
getProviderUsageDayKey,
|
||||
isProviderDailyLimitReached,
|
||||
isRealDebridAccountDailyLimitReached
|
||||
} from "../shared/provider-daily-limits";
|
||||
import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS, DISK_BUSY_STATUS_THRESHOLD_MS } from "./constants";
|
||||
import { parseCollectorInput } from "./link-parser";
|
||||
@@ -61,7 +62,7 @@ function releaseTlsSkip(): void {
|
||||
}
|
||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
|
||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, isRealDebridAccountBlockedForStart, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
|
||||
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor";
|
||||
import { validateFileAgainstManifest } from "./integrity";
|
||||
import { classifyDiskError } from "./fs-error";
|
||||
@@ -8870,7 +8871,9 @@ export class DownloadManager extends EventEmitter {
|
||||
if (effectiveProvider === "realdebrid") {
|
||||
const configuredAccounts = getRealDebridAccounts(this.settings);
|
||||
return configuredAccounts.length > 0
|
||||
? getAvailableRealDebridAccounts(this.settings).length > 0
|
||||
? configuredAccounts.some((account) => account.enabled
|
||||
&& !isRealDebridAccountDailyLimitReached(this.settings, account.id)
|
||||
&& !isRealDebridAccountBlockedForStart(account.id))
|
||||
: Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim());
|
||||
}
|
||||
if (effectiveProvider === "megadebrid-api") {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsP
|
||||
import {
|
||||
validateCollectorContainerPreparationRequest,
|
||||
validateCollectorEnrichmentRequest,
|
||||
validateCollectorPersistenceState,
|
||||
validateCollectorTextPreparationRequest
|
||||
} from "../shared/collector";
|
||||
|
||||
@@ -577,6 +578,19 @@ function registerIpcHandlers(): void {
|
||||
}
|
||||
});
|
||||
});
|
||||
handleTrusted(IPC_CHANNELS.GET_COLLECTOR_STATE, () => controller.getCollectorState());
|
||||
handleTrusted(IPC_CHANNELS.SAVE_COLLECTOR_STATE, (_event: IpcMainInvokeEvent, value: unknown) => {
|
||||
return controller.saveCollectorState(validateCollectorPersistenceState(value));
|
||||
});
|
||||
onTrusted(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, (event: IpcMainEvent, value: unknown) => {
|
||||
try {
|
||||
controller.saveCollectorState(validateCollectorPersistenceState(value));
|
||||
event.returnValue = true;
|
||||
} catch (error) {
|
||||
logger.warn(`Linksammler-Abschlussspeicherung fehlgeschlagen: ${String(error)}`);
|
||||
event.returnValue = false;
|
||||
}
|
||||
});
|
||||
handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts());
|
||||
handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => {
|
||||
validateString(packageId, "packageId");
|
||||
|
||||
+3
-1
@@ -719,6 +719,7 @@ export interface StoragePaths {
|
||||
statisticsFile: string;
|
||||
notificationOutboxFile: string;
|
||||
notificationHealthFile: string;
|
||||
collectorFile: string;
|
||||
}
|
||||
|
||||
export function createStoragePaths(baseDir: string): StoragePaths {
|
||||
@@ -729,7 +730,8 @@ export function createStoragePaths(baseDir: string): StoragePaths {
|
||||
historyFile: path.join(baseDir, "rd_history.json"),
|
||||
statisticsFile: path.join(baseDir, "rd_statistics.json"),
|
||||
notificationOutboxFile: path.join(baseDir, "rd_notification_outbox.json"),
|
||||
notificationHealthFile: path.join(baseDir, "rd_notification_health.json")
|
||||
notificationHealthFile: path.join(baseDir, "rd_notification_health.json"),
|
||||
collectorFile: path.join(baseDir, "rd_collector_state.json")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
CollectorEnrichmentRequest,
|
||||
CollectorEnrichmentProgress,
|
||||
CollectorInspectionResult,
|
||||
CollectorPersistenceState,
|
||||
CollectorTextPreparationRequest
|
||||
} from "../shared/collector";
|
||||
import { IPC_CHANNELS } from "../shared/ipc";
|
||||
@@ -68,6 +69,9 @@ const api: ElectronApi = {
|
||||
ipcRenderer.on(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener);
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener);
|
||||
},
|
||||
getCollectorState: (): Promise<CollectorPersistenceState> => ipcRenderer.invoke(IPC_CHANNELS.GET_COLLECTOR_STATE),
|
||||
saveCollectorState: (state: CollectorPersistenceState): Promise<CollectorPersistenceState> => ipcRenderer.invoke(IPC_CHANNELS.SAVE_COLLECTOR_STATE, state),
|
||||
saveCollectorStateSync: (state: CollectorPersistenceState): void => { ipcRenderer.sendSync(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state); },
|
||||
getPathForDroppedFile: (file: File): string => webUtils.getPathForFile(file),
|
||||
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
|
||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
|
||||
|
||||
+72
-22
@@ -74,6 +74,7 @@ import {
|
||||
selectCollectorPackageLinks,
|
||||
type CollectorWorkspaceFilter
|
||||
} from "./views/collector/collector-model";
|
||||
import { createCollectorPersistenceCoordinator, restoreCollectorPersistenceState, type CollectorPersistenceCoordinator } from "./views/collector/collector-persistence";
|
||||
import {
|
||||
CollectorInputDialog,
|
||||
MemoizedCollectorContent,
|
||||
@@ -106,7 +107,7 @@ import {
|
||||
StatisticsSidebarStatus,
|
||||
type StatisticsViewActions
|
||||
} from "./views/statistics/StatisticsView";
|
||||
import { buildDownloadsViewModel, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, getRemainingDownloadBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
||||
import { buildDownloadsViewModel, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueStatusMetrics, getDownloadSpeedBps, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
||||
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
|
||||
import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog";
|
||||
import { beginDownloadColumnDrag, clearDownloadColumnDrag, commitDownloadColumnDrag, createDownloadColumnOrderPersistence, DOWNLOAD_COLUMN_MOVE_DURATION_MS, updateDownloadColumnDrag, type DownloadColumnDragSession, type DownloadColumnOrderPersistence } from "./views/downloads/column-drag";
|
||||
@@ -1721,6 +1722,9 @@ export function App(): ReactElement {
|
||||
const [collectorError, setCollectorError] = useState("");
|
||||
const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null);
|
||||
const collectorPackagesRef = useRef<CollectorPackage[]>(collectorPackages);
|
||||
const collapsedCollectorPackageIdsRef = useRef<Set<string>>(collapsedCollectorPackageIds);
|
||||
const collectorPersistenceHydratedRef = useRef(false);
|
||||
const collectorPersistenceCoordinatorRef = useRef<CollectorPersistenceCoordinator | null>(null);
|
||||
const collectorEnrichmentGenerationsRef = useRef(new Map<string, number>());
|
||||
const collectorEnrichmentRequestsRef = useRef(new Map<string, ReturnType<typeof beginCollectorEnrichment>>());
|
||||
const importCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve());
|
||||
@@ -1872,6 +1876,7 @@ export function App(): ReactElement {
|
||||
);
|
||||
|
||||
collectorPackagesRef.current = collectorPackages;
|
||||
collapsedCollectorPackageIdsRef.current = collapsedCollectorPackageIds;
|
||||
|
||||
useEffect(() => {
|
||||
activeTabRef.current = tab;
|
||||
@@ -3652,6 +3657,65 @@ export function App(): ReactElement {
|
||||
}, true);
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const coordinator = createCollectorPersistenceCoordinator((state) => window.rd.saveCollectorState(state));
|
||||
collectorPersistenceCoordinatorRef.current = coordinator;
|
||||
const saveBeforeUnload = (): void => {
|
||||
if (!collectorPersistenceHydratedRef.current) return;
|
||||
window.rd.saveCollectorStateSync({
|
||||
packages: collectorPackagesRef.current,
|
||||
collapsedPackageIds: [...collapsedCollectorPackageIdsRef.current]
|
||||
});
|
||||
};
|
||||
window.addEventListener("beforeunload", saveBeforeUnload);
|
||||
void window.rd.getCollectorState().then((persisted) => {
|
||||
if (cancelled) return;
|
||||
const restored = restoreCollectorPersistenceState(
|
||||
persisted,
|
||||
collectorPackagesRef.current,
|
||||
collapsedCollectorPackageIdsRef.current
|
||||
);
|
||||
collectorPackagesRef.current = restored.packages;
|
||||
collapsedCollectorPackageIdsRef.current = new Set(restored.collapsedPackageIds);
|
||||
setCollectorPackages(restored.packages);
|
||||
setCollapsedCollectorPackageIds(new Set(restored.collapsedPackageIds));
|
||||
collectorPersistenceHydratedRef.current = true;
|
||||
coordinator.schedule(restored);
|
||||
if (restored.packages.length > 0) enrichCollectorResult(restored.packages);
|
||||
}).catch((error) => {
|
||||
if (cancelled) return;
|
||||
collectorPersistenceHydratedRef.current = true;
|
||||
setCollectorError(`Linksammler konnte nicht wiederhergestellt werden: ${String(error)}`);
|
||||
coordinator.schedule({
|
||||
packages: collectorPackagesRef.current,
|
||||
collapsedPackageIds: [...collapsedCollectorPackageIdsRef.current]
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("beforeunload", saveBeforeUnload);
|
||||
if (!collectorPersistenceHydratedRef.current) {
|
||||
coordinator.dispose();
|
||||
return;
|
||||
}
|
||||
coordinator.schedule({
|
||||
packages: collectorPackagesRef.current,
|
||||
collapsedPackageIds: [...collapsedCollectorPackageIdsRef.current]
|
||||
});
|
||||
void coordinator.flush().finally(() => coordinator.dispose());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
collapsedCollectorPackageIdsRef.current = collapsedCollectorPackageIds;
|
||||
if (!collectorPersistenceHydratedRef.current) return;
|
||||
collectorPersistenceCoordinatorRef.current?.schedule({
|
||||
packages: collectorPackages,
|
||||
collapsedPackageIds: [...collapsedCollectorPackageIds]
|
||||
});
|
||||
}, [collapsedCollectorPackageIds, collectorPackages]);
|
||||
|
||||
const importCollectorText = async (rawText: string): Promise<void> => {
|
||||
if (!rawText.trim()) {
|
||||
showToast("Keine Links eingegeben", 2200);
|
||||
@@ -4877,21 +4941,6 @@ export function App(): ReactElement {
|
||||
return map;
|
||||
}, [snapshot.packageSpeedBps]);
|
||||
|
||||
const providerStats = useMemo(() => {
|
||||
const stats: Record<string, { total: number; completed: number; failed: number; bytes: number }> = {};
|
||||
for (const item of Object.values(snapshot.session.items)) {
|
||||
const hoster = extractHoster(item.url) || "unknown";
|
||||
if (!stats[hoster]) {
|
||||
stats[hoster] = { total: 0, completed: 0, failed: 0, bytes: 0 };
|
||||
}
|
||||
stats[hoster].total += 1;
|
||||
if (item.status === "completed") stats[hoster].completed += 1;
|
||||
if (item.status === "failed") stats[hoster].failed += 1;
|
||||
stats[hoster].bytes += item.downloadedBytes;
|
||||
}
|
||||
return Object.entries(stats);
|
||||
}, [snapshot.session.items]);
|
||||
|
||||
const sortDownloadsByColumn = useCallback((column: DownloadSortColumn): void => {
|
||||
const nextDescending = downloadsSortColumn === column ? !downloadsSortDescending : false;
|
||||
setDownloadsSortColumn(column);
|
||||
@@ -5018,8 +5067,9 @@ export function App(): ReactElement {
|
||||
if (!snapshot.session.running || snapshot.session.paused) return;
|
||||
speedHistoryRef.current = appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps);
|
||||
}, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]);
|
||||
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
||||
const downloadRemaining = useMemo(() => getRemainingDownloadBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
||||
const downloadQueueMetrics = useMemo(() => getDownloadQueueStatusMetrics(downloadsViewCore.eligibleItems), [downloadsViewCore.eligibleItems]);
|
||||
const downloadQueueTotalBytes = downloadQueueMetrics.totalBytes;
|
||||
const downloadRemaining = downloadQueueMetrics.remaining;
|
||||
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
||||
...downloadsViewCore,
|
||||
running: snapshot.session.running,
|
||||
@@ -5051,8 +5101,8 @@ export function App(): ReactElement {
|
||||
disclosureRevision: downloadDisclosureRevision,
|
||||
animationsEnabled: snapshot.settings.animatePackageDisclosure,
|
||||
status: {
|
||||
packages: snapshot.stats.totalPackages,
|
||||
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
|
||||
packages: downloadQueueMetrics.packageCount,
|
||||
links: downloadQueueMetrics.pendingItemCount,
|
||||
session: humanSize(snapshot.stats.totalDownloaded),
|
||||
sessionBytes: snapshot.stats.totalDownloaded,
|
||||
total: humanSize(downloadQueueTotalBytes),
|
||||
@@ -5060,11 +5110,11 @@ export function App(): ReactElement {
|
||||
remaining: formatRemainingDownloadBytes(downloadRemaining),
|
||||
remainingBytes: downloadRemaining.bytes,
|
||||
remainingTooltip: formatRemainingDownloadTooltip(downloadRemaining),
|
||||
hosters: providerStats.length,
|
||||
hosters: downloadQueueMetrics.hosterCount,
|
||||
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
||||
eta: snapshot.etaText
|
||||
}
|
||||
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueMetrics.hosterCount, downloadQueueMetrics.packageCount, downloadQueueMetrics.pendingItemCount, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded]);
|
||||
|
||||
const resetColumnLayout = useCallback((): void => {
|
||||
if (columnDragSettleTimerRef.current !== null) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
validateCollectorPersistenceState,
|
||||
type CollectorPackage,
|
||||
type CollectorPersistenceState
|
||||
} from "../../../shared/collector";
|
||||
import {
|
||||
mergeCollectorPackages,
|
||||
reconcileCollectorCollapsedPackageIds
|
||||
} from "./collector-model";
|
||||
|
||||
export function restoreCollectorPersistenceState(
|
||||
persisted: CollectorPersistenceState,
|
||||
currentPackages: CollectorPackage[],
|
||||
currentCollapsedPackageIds: Set<string>
|
||||
): CollectorPersistenceState {
|
||||
const merged = mergeCollectorPackages(persisted.packages, currentPackages).packages;
|
||||
const persistedCollapsed = reconcileCollectorCollapsedPackageIds(
|
||||
new Set(persisted.collapsedPackageIds),
|
||||
persisted.packages,
|
||||
merged,
|
||||
[],
|
||||
false
|
||||
);
|
||||
const currentCollapsed = reconcileCollectorCollapsedPackageIds(
|
||||
currentCollapsedPackageIds,
|
||||
currentPackages,
|
||||
merged,
|
||||
[],
|
||||
false
|
||||
);
|
||||
return validateCollectorPersistenceState({
|
||||
packages: merged,
|
||||
collapsedPackageIds: [...new Set([...persistedCollapsed, ...currentCollapsed])]
|
||||
});
|
||||
}
|
||||
|
||||
export interface CollectorPersistenceCoordinator {
|
||||
schedule: (state: CollectorPersistenceState) => void;
|
||||
flush: () => Promise<void>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createCollectorPersistenceCoordinator(
|
||||
save: (state: CollectorPersistenceState) => Promise<CollectorPersistenceState>,
|
||||
delayMs = 300
|
||||
): CollectorPersistenceCoordinator {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pending: CollectorPersistenceState | null = null;
|
||||
let running: Promise<void> | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const drain = (): Promise<void> => {
|
||||
if (running) return running;
|
||||
const task = (async () => {
|
||||
while (pending && !disposed) {
|
||||
const state = pending;
|
||||
pending = null;
|
||||
await save(state);
|
||||
}
|
||||
})().finally(() => {
|
||||
if (running === task) running = null;
|
||||
});
|
||||
running = task;
|
||||
return task;
|
||||
};
|
||||
|
||||
return {
|
||||
schedule: (state) => {
|
||||
if (disposed) return;
|
||||
pending = validateCollectorPersistenceState(state);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
void drain().catch(() => {});
|
||||
}, Math.max(0, delayMs));
|
||||
},
|
||||
flush: async () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
await drain();
|
||||
},
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
pending = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
|
||||
import { humanSize } from "../../download-format";
|
||||
import { extractHoster, humanSize } from "../../download-format";
|
||||
import { DOWNLOAD_FILE_ROW_HEIGHT, DOWNLOAD_PACKAGE_ROW_HEIGHT, type DownloadVirtualRowInput } from "./download-virtualizer";
|
||||
|
||||
export type DownloadDisplayMode = "packages" | "files";
|
||||
@@ -40,10 +40,12 @@ export interface DownloadsViewModelCore {
|
||||
displayMode: DownloadDisplayMode;
|
||||
filter: DownloadSidebarFilter;
|
||||
providerFilter: string;
|
||||
providerOptions: Array<{ id: string; label: string }>;
|
||||
query: string;
|
||||
counts: DownloadFilterCounts;
|
||||
packageRows: DownloadPackageRow[];
|
||||
providerOptions: Array<{ id: string; label: string }>;
|
||||
query: string;
|
||||
counts: DownloadFilterCounts;
|
||||
eligibleItems: DownloadItem[];
|
||||
eligiblePackageCount: number;
|
||||
packageRows: DownloadPackageRow[];
|
||||
fileRows: DownloadItem[];
|
||||
visibleItemIds: string[];
|
||||
visibleRowIds: string[];
|
||||
@@ -116,6 +118,22 @@ export function getRemainingDownloadBytes(items: Iterable<DownloadItem>): { byte
|
||||
return { bytes, unknownItems };
|
||||
}
|
||||
|
||||
export function getDownloadQueueStatusMetrics(items: readonly DownloadItem[]): {
|
||||
packageCount: number;
|
||||
pendingItemCount: number;
|
||||
totalBytes: number;
|
||||
remaining: { bytes: number; unknownItems: number };
|
||||
hosterCount: number;
|
||||
} {
|
||||
return {
|
||||
packageCount: new Set(items.map((item) => item.packageId)).size,
|
||||
pendingItemCount: getPendingDownloadItemCount(items),
|
||||
totalBytes: getDownloadQueueTotalBytes(items),
|
||||
remaining: getRemainingDownloadBytes(items),
|
||||
hosterCount: new Set(items.map((item) => extractHoster(item.url)).filter(Boolean)).size
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRemainingDownloadBytes(summary: { bytes: number; unknownItems: number }): string {
|
||||
const value = summary.bytes >= 1024 ** 4
|
||||
? `${(summary.bytes / 1024 ** 4).toFixed(4)} TB`
|
||||
@@ -170,13 +188,15 @@ export function buildDownloadLogicalRows(model: Pick<DownloadsViewModelCore, "di
|
||||
}
|
||||
|
||||
export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsViewModelCore {
|
||||
const allPackages = input.packageOrder
|
||||
.map((id) => input.packages[id])
|
||||
.filter((entry): entry is PackageEntry => Boolean(entry));
|
||||
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
|
||||
const counts = buildDownloadSidebarCounts(allItems);
|
||||
const providerMap = new Map<string, string>();
|
||||
for (const entry of allItems) {
|
||||
const allPackages = input.packageOrder
|
||||
.map((id) => input.packages[id])
|
||||
.filter((entry): entry is PackageEntry => Boolean(entry));
|
||||
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
|
||||
const eligibleItems = input.hideExtractedItems ? allItems.filter((item) => !isExtracted(item)) : allItems;
|
||||
const eligiblePackageCount = new Set(eligibleItems.map((item) => item.packageId)).size;
|
||||
const counts = buildDownloadSidebarCounts(eligibleItems);
|
||||
const providerMap = new Map<string, string>();
|
||||
for (const entry of eligibleItems) {
|
||||
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
|
||||
}
|
||||
|
||||
@@ -204,7 +224,7 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
const visibleItems = packageMatchesQuery && query !== ""
|
||||
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
|
||||
: matchingItems;
|
||||
return [{ package: entry, items: visibleItems, allItems: allPackageItems, collapsed: collapsed.has(entry.id) }];
|
||||
return [{ package: entry, items: visibleItems, allItems: items, collapsed: collapsed.has(entry.id) }];
|
||||
});
|
||||
|
||||
const totalPackageRows = packageRows.length;
|
||||
@@ -231,10 +251,12 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
displayMode: input.displayMode,
|
||||
filter: input.filter,
|
||||
providerFilter: input.providerFilter,
|
||||
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
|
||||
query: input.query,
|
||||
counts,
|
||||
packageRows: displayedPackages,
|
||||
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
|
||||
query: input.query,
|
||||
counts,
|
||||
eligibleItems,
|
||||
eligiblePackageCount,
|
||||
packageRows: displayedPackages,
|
||||
fileRows,
|
||||
visibleItemIds,
|
||||
visibleRowIds,
|
||||
@@ -245,7 +267,7 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
totalMainRowCount,
|
||||
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
|
||||
limited: false,
|
||||
empty: allItems.length === 0,
|
||||
filteredEmpty: allItems.length > 0 && mainRowCount === 0
|
||||
empty: eligibleItems.length === 0,
|
||||
filteredEmpty: eligibleItems.length > 0 && mainRowCount === 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ export interface CollectorInspectionResult {
|
||||
duplicateCount: number;
|
||||
}
|
||||
|
||||
export interface CollectorPersistenceState {
|
||||
packages: CollectorPackage[];
|
||||
collapsedPackageIds: string[];
|
||||
}
|
||||
|
||||
function validAddedAt(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
@@ -147,6 +152,31 @@ export function validateCollectorEnrichmentRequest(value: unknown): CollectorEnr
|
||||
return { requestId: raw.requestId, packages: structuredClone(raw.packages) as CollectorPackage[] };
|
||||
}
|
||||
|
||||
export function validateCollectorPersistenceState(value: unknown): CollectorPersistenceState {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Linksammler-Speicherzustand ist ungültig");
|
||||
}
|
||||
const raw = value as Record<string, unknown>;
|
||||
const packages = raw.packages;
|
||||
const collapsedPackageIds = raw.collapsedPackageIds;
|
||||
if (Object.keys(raw).some((key) => key !== "packages" && key !== "collapsedPackageIds")
|
||||
|| !Array.isArray(packages)
|
||||
|| packages.length > 2_000
|
||||
|| packages.some((entry) => !validCollectorPackage(entry))
|
||||
|| packages.reduce((sum, entry) => sum + (entry as CollectorPackage).links.length, 0) > 20_000
|
||||
|| !Array.isArray(collapsedPackageIds)
|
||||
|| collapsedPackageIds.length > 2_000
|
||||
|| collapsedPackageIds.some((entry) => typeof entry !== "string" || entry.length === 0 || entry.length > 160)) {
|
||||
throw new Error("Linksammler-Speicherzustand ist ungültig");
|
||||
}
|
||||
const packageIds = new Set(packages.map((pkg) => (pkg as CollectorPackage).id));
|
||||
const collapsed = [...new Set(collapsedPackageIds)].filter((id) => packageIds.has(id));
|
||||
return {
|
||||
packages: structuredClone(packages) as CollectorPackage[],
|
||||
collapsedPackageIds: collapsed
|
||||
};
|
||||
}
|
||||
|
||||
function collectorMarkerValue(value: string): string {
|
||||
return String(value || "").replace(/[\r\n]+/g, " ").trim();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ export const IPC_CHANNELS = {
|
||||
PREPARE_COLLECTOR_CONTAINERS: "collector:prepare-containers",
|
||||
ENRICH_COLLECTOR_PACKAGES: "collector:enrich-packages",
|
||||
COLLECTOR_ENRICHMENT_PROGRESS: "collector:enrichment-progress",
|
||||
GET_COLLECTOR_STATE: "collector:get-state",
|
||||
SAVE_COLLECTOR_STATE: "collector:save-state",
|
||||
SAVE_COLLECTOR_STATE_SYNC: "collector:save-state-sync",
|
||||
GET_START_CONFLICTS: "queue:get-start-conflicts",
|
||||
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
|
||||
CLEAR_ALL: "queue:clear-all",
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
CollectorEnrichmentRequest,
|
||||
CollectorEnrichmentProgress,
|
||||
CollectorInspectionResult,
|
||||
CollectorPersistenceState,
|
||||
CollectorTextPreparationRequest
|
||||
} from "./collector";
|
||||
|
||||
@@ -89,6 +90,9 @@ export interface ElectronApi {
|
||||
prepareCollectorContainers: (filePaths: string[], addedAt: number) => Promise<CollectorInspectionResult>;
|
||||
enrichCollectorPackages: (request: CollectorEnrichmentRequest) => Promise<CollectorInspectionResult>;
|
||||
onCollectorEnrichmentProgress: (callback: (progress: CollectorEnrichmentProgress) => void) => () => void;
|
||||
getCollectorState: () => Promise<CollectorPersistenceState>;
|
||||
saveCollectorState: (state: CollectorPersistenceState) => Promise<CollectorPersistenceState>;
|
||||
saveCollectorStateSync: (state: CollectorPersistenceState) => void;
|
||||
getPathForDroppedFile: (file: File) => string;
|
||||
getStartConflicts: () => Promise<StartConflictEntry[]>;
|
||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
|
||||
|
||||
Reference in New Issue
Block a user