diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c1ba912..2cc9595 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4649,7 +4649,7 @@ export function App(): ReactElement { sortColumn: downloadsSortColumn, sortDirection: downloadsSortDescending ? "desc" : "asc", disclosureRevision: downloadDisclosureRevision, - animatePackageDisclosure: snapshot.settings.animatePackageDisclosure, + animationsEnabled: snapshot.settings.animatePackageDisclosure, status: { packages: snapshot.stats.totalPackages, links: getPendingDownloadItemCount(Object.values(snapshot.session.items)), diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 0f7bd76..abc4f67 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -12,7 +12,7 @@ const pairs = [ ["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"], ["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."], ["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"], - ["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"], ["Paket-Ein-/Ausklappen animieren", "Animate package expand/collapse"], + ["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"], ["Animationen", "Animations"], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"], ["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"], ["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"], diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index 523ac4d..f1327ac 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -48,7 +48,7 @@ export interface DownloadsViewModel extends DownloadsViewModelCore { sortColumn?: DownloadSortColumn; sortDirection?: "asc" | "desc"; disclosureRevision: number; - animatePackageDisclosure: boolean; + animationsEnabled: boolean; status: DownloadsStatusModel; } diff --git a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx index f80bdf1..22a214e 100644 --- a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx +++ b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx @@ -10,6 +10,14 @@ import { stableDownloadDisclosureRows, type DownloadDisclosureRow } from "./download-disclosure-transition"; +import { + DOWNLOAD_ORDER_TRANSITION_DURATION_MS, + animateDownloadOrderRows, + captureDownloadOrderRowTops, + getDownloadOrderTransitionPinnedIds, + getDownloadPackageOrder, + isDownloadPackageOrderChange +} from "./download-order-transition"; import type { DownloadsViewActions, DownloadsViewModel } from "./DownloadsView"; import { ItemRow, PackageCard } from "./DownloadsTable"; import { calculateDownloadVirtualWindow, DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT, DOWNLOAD_VIRTUAL_OVERSCAN_ROWS } from "./download-virtualizer"; @@ -111,6 +119,22 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D const transitionRowsRef = useRef(null); const transitionPinnedIdsRef = useRef([]); const [transitionRows, setTransitionRows] = useState(null); + const packageOrder = getDownloadPackageOrder(desiredRows); + const previousPackageOrderRef = useRef(packageOrder); + const previousVisibleIdsRef = useRef([]); + const orderTransitionPinnedIdsRef = useRef([]); + const orderTransitionTimerRef = useRef(0); + const orderAnimationsRef = useRef([]); + const orderRowTopsRef = useRef>(new Map()); + const [, setOrderTransitionRevision] = useState(0); + const packageOrderChanged = isDownloadPackageOrderChange(previousPackageOrderRef.current, packageOrder); + const orderTransitionPinnedIds = getDownloadOrderTransitionPinnedIds({ + enabled: model.animationsEnabled, + previousOrder: previousPackageOrderRef.current, + nextOrder: packageOrder, + previousVisibleIds: previousVisibleIdsRef.current, + activePinnedIds: orderTransitionPinnedIdsRef.current + }); useEffect(() => { if (!transitionRowsRef.current) previousRowsRef.current = desiredRows; @@ -120,7 +144,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D const prepared = prepareDownloadDisclosureTransition( transitionRowsRef.current ?? previousRowsRef.current, desiredRowsRef.current, - model.animatePackageDisclosure + model.animationsEnabled ); if (!prepared.animated) { transitionRowsRef.current = null; @@ -152,24 +176,64 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D cancelActivation(); if (settleTimer) window.clearTimeout(settleTimer); }; - }, [model.animatePackageDisclosure, model.disclosureRevision, model.displayMode]); + }, [model.animationsEnabled, model.disclosureRevision, model.displayMode]); const renderedRows = useMemo(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, transitionRows]); const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, { scrollTop: viewport.scrollTop, viewportHeight: viewport.viewportHeight, overscan: DOWNLOAD_VIRTUAL_OVERSCAN_ROWS, - pinnedIds: [model.editingPackageId, ...transitionPinnedIdsRef.current] - }), [model.editingPackageId, renderedRows, viewport.scrollTop, viewport.viewportHeight]); + pinnedIds: [model.editingPackageId, ...transitionPinnedIdsRef.current, ...orderTransitionPinnedIds] + }), [model.editingPackageId, orderTransitionPinnedIds, renderedRows, viewport.scrollTop, viewport.viewportHeight]); + + useRendererLayoutEffect(() => { + previousPackageOrderRef.current = packageOrder; + previousVisibleIdsRef.current = virtualWindow.rows.map((entry) => entry.id); + const body = bodyRef.current; + if (!body) return; + if (!model.animationsEnabled) { + for (const animation of orderAnimationsRef.current) animation.cancel(); + orderAnimationsRef.current = []; + orderRowTopsRef.current = captureDownloadOrderRowTops(body); + if (orderTransitionTimerRef.current) window.clearTimeout(orderTransitionTimerRef.current); + orderTransitionTimerRef.current = 0; + orderTransitionPinnedIdsRef.current = []; + return; + } + if (!packageOrderChanged) { + if (orderAnimationsRef.current.every((animation) => animation.playState === "finished" || animation.playState === "idle")) { + orderAnimationsRef.current = []; + orderRowTopsRef.current = captureDownloadOrderRowTops(body); + } + return; + } + const previousTops = orderAnimationsRef.current.length > 0 ? captureDownloadOrderRowTops(body) : orderRowTopsRef.current; + for (const animation of orderAnimationsRef.current) animation.cancel(); + const animated = animateDownloadOrderRows(body, previousTops); + orderAnimationsRef.current = animated.animations; + orderRowTopsRef.current = animated.targetTops; + orderTransitionPinnedIdsRef.current = orderTransitionPinnedIds; + if (orderTransitionTimerRef.current) window.clearTimeout(orderTransitionTimerRef.current); + orderTransitionTimerRef.current = window.setTimeout(() => { + orderTransitionPinnedIdsRef.current = []; + orderTransitionTimerRef.current = 0; + setOrderTransitionRevision((revision) => revision + 1); + }, DOWNLOAD_ORDER_TRANSITION_DURATION_MS); + }, [model.animationsEnabled, orderTransitionPinnedIds, packageOrder, packageOrderChanged, virtualWindow.rows]); + + useEffect(() => () => { + if (orderTransitionTimerRef.current) window.clearTimeout(orderTransitionTimerRef.current); + for (const animation of orderAnimationsRef.current) animation.cancel(); + }, []); const spacerStyle = { "--downloads-virtual-total-height": `${virtualWindow.totalHeight}px` } as CSSProperties; return ( -
+
{state} {!state ? (
{virtualWindow.rows.map((entry) => ( -
+
{renderVirtualRow(entry.source, model, actions)}
))} diff --git a/src/renderer/views/downloads/download-order-transition.ts b/src/renderer/views/downloads/download-order-transition.ts new file mode 100644 index 0000000..cfa1a23 --- /dev/null +++ b/src/renderer/views/downloads/download-order-transition.ts @@ -0,0 +1,67 @@ +import type { DownloadLogicalRow } from "./downloads-model"; + +export const DOWNLOAD_ORDER_TRANSITION_DURATION_MS = 1500; +export const DOWNLOAD_ORDER_TRANSITION_MAX_PINNED_ROWS = 96; +const DOWNLOAD_ORDER_TRANSITION_EASING = "cubic-bezier(0.22, 1, 0.36, 1)"; + +export interface DownloadOrderAnimationResult { + animations: Animation[]; + targetTops: Map; +} + +export function getDownloadPackageOrder(rows: readonly DownloadLogicalRow[]): string[] { + return rows.filter((row) => row.type === "package").map((row) => row.id); +} + +export function isDownloadPackageOrderChange(previous: readonly string[], next: readonly string[]): boolean { + if (previous.length !== next.length || previous.length < 2) return false; + const previousIds = new Set(previous); + if (next.some((id) => !previousIds.has(id))) return false; + return next.some((id, index) => previous[index] !== id); +} + +export function getDownloadOrderTransitionPinnedIds(input: { + enabled: boolean; + previousOrder: readonly string[]; + nextOrder: readonly string[]; + previousVisibleIds: readonly string[]; + activePinnedIds: readonly string[]; +}): string[] { + if (!input.enabled) return []; + if (!isDownloadPackageOrderChange(input.previousOrder, input.nextOrder)) return [...input.activePinnedIds]; + return [...new Set([...input.activePinnedIds, ...input.previousVisibleIds])].slice(0, DOWNLOAD_ORDER_TRANSITION_MAX_PINNED_ROWS); +} + +export function captureDownloadOrderRowTops(container: HTMLElement): Map { + const tops = new Map(); + for (const element of container.querySelectorAll(".downloads-virtual-row[data-download-row-id]")) { + const id = element.dataset.downloadRowId; + if (id) tops.set(id, element.getBoundingClientRect().top); + } + return tops; +} + +export function getDownloadOrderTransformKeyframes(virtualTop: number, previousTop: number, targetTop: number): Keyframe[] { + const offset = previousTop - targetTop; + return [ + { transform: `translateY(${virtualTop + offset}px)` }, + { transform: `translateY(${virtualTop}px)` } + ]; +} + +export function animateDownloadOrderRows(container: HTMLElement, previousTops: ReadonlyMap): DownloadOrderAnimationResult { + const targetTops = captureDownloadOrderRowTops(container); + const animations: Animation[] = []; + for (const element of container.querySelectorAll(".downloads-virtual-row[data-download-row-id]")) { + const id = element.dataset.downloadRowId; + const previousTop = id ? previousTops.get(id) : undefined; + const targetTop = id ? targetTops.get(id) : undefined; + const virtualTop = Number.parseFloat(element.style.getPropertyValue("--downloads-virtual-row-top")); + if (previousTop === undefined || targetTop === undefined || !Number.isFinite(virtualTop) || Math.abs(previousTop - targetTop) < 0.5) continue; + animations.push(element.animate(getDownloadOrderTransformKeyframes(virtualTop, previousTop, targetTop), { + duration: DOWNLOAD_ORDER_TRANSITION_DURATION_MS, + easing: DOWNLOAD_ORDER_TRANSITION_EASING + })); + } + return { animations, targetTops }; +} diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index aeef5a7..a6c4a62 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -302,8 +302,8 @@ transition: height 1500ms cubic-bezier(0.22, 1, 0.36, 1), transform 1500ms cubic-bezier(0.22, 1, 0.36, 1), opacity 1500ms cubic-bezier(0.22, 1, 0.36, 1); } -.downloads-table-body.is-disclosure-motion-disabled .downloads-virtual-spacer, -.downloads-table-body.is-disclosure-motion-disabled .downloads-virtual-row { +.downloads-table-body.is-download-motion-disabled .downloads-virtual-spacer, +.downloads-table-body.is-download-motion-disabled .downloads-virtual-row { transition: none !important; } diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index 7392413..0c2b8a6 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -568,7 +568,7 @@ export function buildSettingsFormViewModel({ title: "Oberfläche und Bedienung", fields: [ { id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages }, - { id: "animatePackageDisclosure", kind: "switch", label: "Paket-Ein-/Ausklappen animieren", value: settings.animatePackageDisclosure }, + { id: "animatePackageDisclosure", kind: "switch", label: "Animationen", value: settings.animatePackageDisclosure }, { id: "minimizeToTray", kind: "switch", label: "In den Infobereich minimieren", value: settings.minimizeToTray }, { id: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection }, { id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads }, diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 24fa585..fc64339 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -28,6 +28,12 @@ import { scheduleDownloadDisclosureActivation, stableDownloadDisclosureRows } from "../src/renderer/views/downloads/download-disclosure-transition"; +import { + DOWNLOAD_ORDER_TRANSITION_DURATION_MS, + getDownloadOrderTransitionPinnedIds, + getDownloadOrderTransformKeyframes, + isDownloadPackageOrderChange +} from "../src/renderer/views/downloads/download-order-transition"; import { DownloadsContent, DownloadsFooter, @@ -241,12 +247,36 @@ describe("virtualisierte Paketanimation", () => { expect(prepared.rows.some((row) => row.type === "item-group")).toBe(false); }); - it("deaktiviert auch die CSS-Bewegung der virtuellen Folgezeilen", () => { - const html = renderToStaticMarkup(); + it("deaktiviert alle Downloadbewegungen über den allgemeinen Animationsschalter", () => { + const html = renderToStaticMarkup(); const css = readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8"); - expect(html).toContain('class="downloads-table-body is-disclosure-motion-disabled"'); - expect(css).toMatch(/\.downloads-table-body\.is-disclosure-motion-disabled \.downloads-virtual-spacer,\s*\.downloads-table-body\.is-disclosure-motion-disabled \.downloads-virtual-row\s*\{[^}]*transition:\s*none !important;/s); + expect(html).toContain('class="downloads-table-body is-download-motion-disabled"'); + expect(css).toMatch(/\.downloads-table-body\.is-download-motion-disabled \.downloads-virtual-spacer,\s*\.downloads-table-body\.is-download-motion-disabled \.downloads-virtual-row\s*\{[^}]*transition:\s*none !important;/s); + }); + + it("pins only previously visible rows for a real priority reorder", () => { + expect(DOWNLOAD_ORDER_TRANSITION_DURATION_MS).toBe(1500); + expect(isDownloadPackageOrderChange(["a", "b", "c"], ["b", "c", "a"])).toBe(true); + expect(isDownloadPackageOrderChange(["a", "b"], ["a", "c"])).toBe(false); + expect(getDownloadOrderTransitionPinnedIds({ + enabled: true, + previousOrder: ["a", "b", "c"], + nextOrder: ["b", "c", "a"], + previousVisibleIds: ["a", "a:items", "b"], + activePinnedIds: [] + })).toEqual(["a", "a:items", "b"]); + expect(getDownloadOrderTransitionPinnedIds({ + enabled: false, + previousOrder: ["a", "b", "c"], + nextOrder: ["b", "c", "a"], + previousVisibleIds: ["a", "b"], + activePinnedIds: ["c"] + })).toEqual([]); + expect(getDownloadOrderTransformKeyframes(240, 159, 315)).toEqual([ + { transform: "translateY(84px)" }, + { transform: "translateY(240px)" } + ]); }); it("zeichnet den Startzustand in einem eigenen Frame vor der Aktivierung", () => { @@ -681,7 +711,7 @@ function withRuntime(input: DownloadsModelInput, overrides: Record { expect(source).toMatch(/disabled=\{allMatch\}[\s\S]{0,260}window\.rd\.setPackagePriority/); }); + it("keeps previously visible package rows mounted while priority order glides", () => { + const source = readFileSync(new URL("../src/renderer/views/downloads/VirtualizedDownloadsBody.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n"); + + expect(source).toContain("getDownloadOrderTransitionPinnedIds"); + expect(source).toContain("animateDownloadOrderRows"); + expect(source).toContain("orderTransitionPinnedIdsRef.current"); + expect(source).toContain("data-download-row-id={entry.id}"); + expect(source).toContain("DOWNLOAD_ORDER_TRANSITION_DURATION_MS"); + }); + it("updates the clipboard checkbox optimistically before IPC reconciliation", () => { const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n"); diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index a78d63c..f923615 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -12,6 +12,8 @@ describe("renderer localization", () => { expect(translateUiText("Einstellungen speichern", "en")).toBe("Save settings"); expect(translateUiText("Save settings", "de")).toBe("Einstellungen speichern"); expect(translateUiText("Passwort/Zugang", "en")).toBe("Password/access"); + expect(translateUiText("Animationen", "en")).toBe("Animations"); + expect(translateUiText("Animations", "de")).toBe("Animationen"); }); it("translates dynamic update and pagination text", () => { diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 138619c..0fa21d4 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -501,7 +501,7 @@ describe("settings views", () => { expect(settingsCss).toMatch(/\.settings-select\.is-open\s+\.settings-select-options\s*\{[^}]*opacity:\s*1[^}]*transform:\s*translateY\(0\)/s); }); - it("offers a general switch for package expand and collapse motion", () => { + it("offers one general switch for interface animations", () => { const form = buildSettingsFormViewModel({ settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" }, section: "allgemein", @@ -514,7 +514,7 @@ describe("settings views", () => { expect(animation).toEqual({ id: "animatePackageDisclosure", kind: "switch", - label: "Paket-Ein-/Ausklappen animieren", + label: "Animationen", value: true }); }); diff --git a/tests/visual-fixtures.test.ts b/tests/visual-fixtures.test.ts index 8ccc1c1..426f603 100644 --- a/tests/visual-fixtures.test.ts +++ b/tests/visual-fixtures.test.ts @@ -192,6 +192,27 @@ describe("visual fixtures", () => { expect(dense).toHaveProperty("remoteDiagnostics.status.running", false); }); + it("emits the priority-sorted snapshot so visual tests can observe package motion", async () => { + const dense = createVisualFixture("dense"); + const api = createVisualElectronApi(dense); + const snapshots: string[][] = []; + const unsubscribe = api.onStateUpdate((snapshot) => snapshots.push([...snapshot.session.packageOrder])); + const target = dense.snapshot.session.packageOrder[0]; + + await api.setPackagePriority(target, "low"); + + expect(dense.snapshot.session.packageOrder.at(-1)).toBe(target); + expect(snapshots.at(-1)?.at(-1)).toBe(target); + unsubscribe(); + }); + + it("can boot the isolated renderer with animations disabled", async () => { + const dense = createVisualFixture("dense"); + const api = createVisualElectronApi(dense, "?animations=off"); + + expect((await api.getSnapshot()).settings.animatePackageDisclosure).toBe(false); + }); + it("boots the dense query once and waits for both visible package names", async () => { expect(typeof window).toBe("undefined"); const harness = createTestVisualBootstrap( diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index 8909d51..c1e10b8 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -12,11 +12,19 @@ export function createVisualElectronApi( fixture: VisualFixture, search = typeof window === "undefined" ? "" : window.location.search ): ElectronApi { - const historyState = new URLSearchParams(search).get("history-state"); + const searchParams = new URLSearchParams(search); + const historyState = searchParams.get("history-state"); + if (searchParams.get("animations") === "off") fixture.snapshot.settings.animatePackageDisclosure = false; let historyRequestCount = 0; + const stateUpdateListeners = new Set[0]>(); + const emitStateUpdate = (): void => { + const snapshot = clone(fixture.snapshot); + for (const listener of stateUpdateListeners) listener(snapshot); + }; const updateSettings = (settings: RendererSettingsUpdate): RendererSettings => { const { archivePasswordList: _archivePasswordList, notifyUrl: _notifyUrl, ...safe } = settings; Object.assign(fixture.snapshot.settings, safe); + emitStateUpdate(); return clone(fixture.snapshot.settings); }; @@ -376,9 +384,24 @@ export function createVisualElectronApi( : { ok: false, reason: "entry-not-found" }, setPackagePriority: async (packageId, priority) => { const entry = fixture.snapshot.session.packages[packageId]; - if (entry) { - entry.priority = priority; + if (!entry || entry.priority === priority) return; + entry.priority = priority; + const order = fixture.snapshot.session.packageOrder; + const currentIndex = order.indexOf(packageId); + if (currentIndex >= 0) { + order.splice(currentIndex, 1); + const priorityRank = { high: 0, normal: 1, low: 2 } as const; + let insertAt = order.length; + for (let index = 0; index < order.length; index += 1) { + const otherPriority = fixture.snapshot.session.packages[order[index]]?.priority || "normal"; + if (priorityRank[otherPriority] > priorityRank[priority]) { + insertAt = index; + break; + } + } + order.splice(insertAt, 0, packageId); } + emitStateUpdate(); }, skipItems: async (itemIds) => { for (const itemId of itemIds) { @@ -413,7 +436,10 @@ export function createVisualElectronApi( fixture.snapshot.session.running = true; }, reportRendererError: () => {}, - onStateUpdate: () => stableNoopUnsubscribe, + onStateUpdate: (callback) => { + stateUpdateListeners.add(callback); + return () => stateUpdateListeners.delete(callback); + }, onClipboardDetected: () => stableNoopUnsubscribe, onUpdateInstallProgress: () => stableNoopUnsubscribe };