diff --git a/scripts/before-tauri-build.js b/scripts/before-tauri-build.js index 57d7929..ecdcc14 100755 --- a/scripts/before-tauri-build.js +++ b/scripts/before-tauri-build.js @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -41,6 +42,14 @@ function runNpmScript(script) { run('npm', ['run', script]); } +// A packaged Tauri artifact must have its own consent identity. A source or +// commit fingerprint cannot distinguish two fresh signed/package builds made +// from the same checkout, which would let an updated binary silently reuse the +// previous binary's credential-store approval. Keep any configured identity +// as useful provenance, but always add the artifact nonce. +const configuredBuildId = process.env.VITE_BUILD_ID?.trim(); +process.env.VITE_BUILD_ID = `${configuredBuildId || 'artifact'}-${randomUUID()}`; + run(process.execPath, ['scripts/stage-engines.js']); run(process.execPath, ['scripts/verify-binaries.js', '--staged']); diff --git a/src/App.tsx b/src/App.tsx index a92819d..dcc3367 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { schedulerCompletionState } from './utils/schedulerCompletion'; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { Sidebar, SidebarFilter } from "./components/Sidebar"; import { DownloadTable } from "./components/DownloadTable"; +import { KeychainPermissionModal } from './components/KeychainPermissionModal'; import { extractValidDownloadUrls } from './utils/url'; import { readClipboardDownloadUrls } from './utils/clipboard'; import { listenEvent as listen, invokeCommand as invoke } from "./ipc"; @@ -52,10 +53,6 @@ const PropertiesModal = lazy(() => import('./components/PropertiesModal').then(m const DeleteConfirmationModal = lazy(() => import('./components/DeleteConfirmationModal').then(module => ({ default: module.DeleteConfirmationModal, }))); -const KeychainPermissionModal = lazy(() => import('./components/KeychainPermissionModal').then(module => ({ - default: module.KeychainPermissionModal, -}))); - const preloadPageChunks = async () => { for (const load of pageChunkLoaders) { try { @@ -500,14 +497,17 @@ function App() { let changed = false; if (deferKeychainHydration) { settings.setKeychainAccessReady(false); + if (showKeychainPrompt) { + // Commit the explanation before the harmless session-token IPC so + // a slow startup cannot leave the user facing an unexplained + // credential-store request. + settings.setShowKeychainModal(true); + } // This token is already owned by the backend and does not access // the OS credential store. Render our explanation before any native // Keychain/Credential Manager prompt can be user-triggered. await settings.hydrateSessionPairingToken(isStartupActive); if (!active) return; - if (showKeychainPrompt) { - settings.setShowKeychainModal(true); - } } else { // The backend keeps credential-store access disabled for every new // process. Arm it only after the persisted startup decision has diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 2d466de..071e84d 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -98,13 +98,10 @@ export const DownloadItem = React.memo(({ gridColumn: getColumnGridColumn(key, columnOrder), } as React.CSSProperties); - const trailingColumnClass = (key: DownloadTableColumnKey) => - key === columnOrder[columnOrder.length - 1] ? 'download-column-cell--trailing' : ''; - const cells: Record = { 'File Name': (
@@ -119,7 +116,7 @@ export const DownloadItem = React.memo(({ ), Size: (
(({ ), Status: (
{download.status === 'completed' ? ( @@ -202,21 +199,21 @@ export const DownloadItem = React.memo(({
), Speed: ( -
+
{displaySpeed}
), ETA: ( -
+
{displayEta}
), 'Date Added': ( -
+
= ({ filter }) => { const { t } = useTranslation(); const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore(); @@ -110,7 +116,7 @@ export const DownloadTable: React.FC = ({ filter }) => { const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null); const [animationParent] = useAutoAnimate(); - const [headerAnimationParent] = useAutoAnimate({ + const [headerAnimationParent, setHeaderAnimationEnabled] = useAutoAnimate({ duration: 140, easing: 'ease-out', }); @@ -128,8 +134,17 @@ export const DownloadTable: React.FC = ({ filter }) => { } | null>(null); const headerElementsRef = useRef(new Map()); const headerContainerRef = useRef(null); + const headerRef = useCallback((element: HTMLDivElement | null) => { + headerContainerRef.current = element; + headerAnimationParent(element); + }, [headerAnimationParent]); const columnDragStateRef = useRef(null); const columnDragOrderRef = useRef(null); + const columnDragLayoutRef = useRef | null>(null); + const columnDragTargetRef = useRef(null); + const columnDragCaptureTargetRef = useRef(null); + const columnDragCapturePointerIdRef = useRef(null); + const columnDragCleanupRef = useRef<(() => void) | null>(null); const columnDropFlashTimerRef = useRef(null); const suppressHeaderClickRef = useRef(false); const selectedIdsRef = useRef(selectedIds); @@ -166,17 +181,14 @@ export const DownloadTable: React.FC = ({ filter }) => { const [, setMenuViewportVersion] = useState(0); const columnWidthsRef = useRef(columnWidths); const columnOrderRef = useRef(columnOrder); - columnWidthsRef.current = columnWidths; - columnOrderRef.current = columnOrder; - columnDragOrderRef.current = columnDragOrder; - const normalizedColumnWidths = columnWidths.map((width, index) => + const normalizedColumnWidths = columnWidthsRef.current.map((width, index) => Math.max(COLUMN_MINIMUMS[index], width) ); - const orderedColumns = columnDragOrder ?? columnOrder; + const orderedColumns = columnDragOrderRef.current ?? columnDragOrder ?? columnOrder; const tableGridTemplate = buildColumnGridTemplate(orderedColumns, normalizedColumnWidths); const tableMinWidth = normalizedColumnWidths.reduce( (total, width) => total + width, - 0 + DOWNLOAD_ACTIONS_COLUMN_WIDTH ); const tableMinWidthWithPadding = 'calc(' + tableMinWidth + 'px + var(--download-row-padding-x) + var(--download-row-padding-x))'; const downloadsViewRef = useRef(null); @@ -212,6 +224,15 @@ export const DownloadTable: React.FC = ({ filter }) => { }; }; + const captureColumnLayout = (order: DownloadTableColumnKey[]) => { + const layout = new Map(); + for (const key of order) { + const element = headerElementsRef.current.get(key); + if (element) layout.set(key, getColumnLayoutBounds(element)); + } + return layout; + }; + const getColumnDropPosition = ( key: DownloadTableColumnKey, pointerX: number, @@ -221,7 +242,7 @@ export const DownloadTable: React.FC = ({ filter }) => { const targetIndex = remainingColumns.findIndex(columnKey => { const element = headerElementsRef.current.get(columnKey); if (!element) return false; - const bounds = getColumnLayoutBounds(element); + const bounds = columnDragLayoutRef.current?.get(columnKey) ?? getColumnLayoutBounds(element); return pointerX < bounds.left + (bounds.right - bounds.left) / 2; }); const dropIndex = targetIndex === -1 ? remainingColumns.length : targetIndex; @@ -235,78 +256,26 @@ export const DownloadTable: React.FC = ({ filter }) => { return { dropIndex, markerX }; }; - const handleColumnPointerDown = (key: DownloadTableColumnKey, event: React.PointerEvent) => { - if (event.button !== 0) return; - if (event.target instanceof Element && event.target.closest('.column-resize-handle, .download-column-options')) return; - - event.currentTarget.setPointerCapture(event.pointerId); - dragGestureRef.current = { - key, - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - active: false, - }; - }; - - const handleColumnPointerMove = (key: DownloadTableColumnKey, event: React.PointerEvent) => { + const finishColumnDrag = (key: DownloadTableColumnKey, pointerId: number, cancelled = false) => { const gesture = dragGestureRef.current; - if (!gesture || gesture.key !== key || gesture.pointerId !== event.pointerId) return; - - const distance = Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY); - if (!gesture.active) { - if (distance < 5) return; - gesture.active = true; - suppressHeaderClickRef.current = true; - const rect = event.currentTarget.getBoundingClientRect(); - const currentOrder = columnOrderRef.current; - columnDragOrderRef.current = [...currentOrder]; - setColumnDragOrder([...currentOrder]); - const { dropIndex, markerX } = getColumnDropPosition(key, event.clientX, currentOrder); - updateColumnDragState({ - key, - startX: gesture.startX, - pointerX: event.clientX, - offsetX: event.clientX - rect.left, - top: rect.top, - width: rect.width, - dropIndex, - markerX, - }); - } else { - const current = columnDragStateRef.current; - if (!current) return; - const currentOrder = columnDragOrderRef.current ?? columnOrderRef.current; - const { dropIndex, markerX } = getColumnDropPosition(key, event.clientX, currentOrder); - const nextOrder = reorderColumn(currentOrder, key, dropIndex); - const orderChanged = nextOrder.some((columnKey, index) => columnKey !== currentOrder[index]); - if (orderChanged) { - columnDragOrderRef.current = nextOrder; - setColumnDragOrder(nextOrder); - } - updateColumnDragState({ - ...current, - pointerX: event.clientX, - dropIndex: nextOrder.indexOf(key), - markerX, - }); - } - - event.preventDefault(); - }; - - const finishColumnDrag = (key: DownloadTableColumnKey, event: React.PointerEvent, cancelled = false) => { - const gesture = dragGestureRef.current; - if (!gesture || gesture.key !== key || gesture.pointerId !== event.pointerId) return; + if (!gesture || gesture.key !== key || gesture.pointerId !== pointerId) return; const current = columnDragStateRef.current; - dragGestureRef.current = null; - updateColumnDragState(null); const nextOrder = columnDragOrderRef.current ?? columnOrderRef.current; + const captureTarget = columnDragCaptureTargetRef.current; + dragGestureRef.current = null; + columnDragTargetRef.current = null; + columnDragCaptureTargetRef.current = null; + columnDragCapturePointerIdRef.current = null; columnDragOrderRef.current = null; + columnDragLayoutRef.current = null; + columnDragCleanupRef.current?.(); + columnDragCleanupRef.current = null; + updateColumnDragState(null); setColumnDragOrder(null); - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); + setHeaderAnimationEnabled(true); + if (captureTarget?.hasPointerCapture(pointerId)) { + captureTarget.releasePointerCapture(pointerId); } if (cancelled || !gesture.active || !current) { suppressHeaderClickRef.current = false; @@ -318,6 +287,7 @@ export const DownloadTable: React.FC = ({ filter }) => { if (nextOrder.join('|') === columnOrderRef.current.join('|')) return; + columnOrderRef.current = nextOrder; setColumnOrder(nextOrder); persistColumnOrder(nextOrder); if (columnDropFlashTimerRef.current !== null) { @@ -330,6 +300,134 @@ export const DownloadTable: React.FC = ({ filter }) => { }, 260); }; + const handleColumnPointerMove = ( + key: DownloadTableColumnKey, + pointerId: number, + clientX: number, + clientY: number, + preventDefault?: () => void + ) => { + const gesture = dragGestureRef.current; + if (!gesture || gesture.key !== key || gesture.pointerId !== pointerId) return; + + const distance = Math.hypot(clientX - gesture.startX, clientY - gesture.startY); + if (!gesture.active) { + if (distance < 5) return; + gesture.active = true; + suppressHeaderClickRef.current = true; + const target = columnDragTargetRef.current; + const rect = target?.getBoundingClientRect(); + if (!rect) return; + // The header children are reordered in the live grid while dragging. + // AutoAnimate's MutationObserver must not animate the same structural + // changes that drive pointer hit-testing; it is restored on completion. + setHeaderAnimationEnabled(false); + const currentOrder = columnOrderRef.current; + columnDragLayoutRef.current = captureColumnLayout(currentOrder); + columnDragOrderRef.current = [...currentOrder]; + setColumnDragOrder([...currentOrder]); + const { dropIndex, markerX } = getColumnDropPosition(key, clientX, currentOrder); + updateColumnDragState({ + key, + startX: gesture.startX, + pointerX: clientX, + offsetX: clientX - rect.left, + top: rect.top, + width: rect.width, + dropIndex, + markerX, + }); + } else { + const current = columnDragStateRef.current; + if (!current) return; + const currentOrder = columnDragOrderRef.current ?? columnOrderRef.current; + const { dropIndex, markerX } = getColumnDropPosition(key, clientX, currentOrder); + const nextOrder = reorderColumn(currentOrder, key, dropIndex); + const orderChanged = nextOrder.some((columnKey, index) => columnKey !== currentOrder[index]); + if (orderChanged) { + columnDragOrderRef.current = nextOrder; + setColumnDragOrder(nextOrder); + } + updateColumnDragState({ + ...current, + pointerX: clientX, + dropIndex: nextOrder.indexOf(key), + markerX, + }); + } + + preventDefault?.(); + }; + + const handleColumnPointerDown = (key: DownloadTableColumnKey, event: React.PointerEvent) => { + if (event.button !== 0) return; + if (event.target instanceof Element && event.target.closest('.column-resize-handle, .download-column-options')) return; + + const existingGesture = dragGestureRef.current; + if (existingGesture) { + finishColumnDrag(existingGesture.key, existingGesture.pointerId, true); + } + const target = event.currentTarget; + const pointerId = event.pointerId; + const captureTarget = headerContainerRef.current; + columnDragTargetRef.current = target; + columnDragCaptureTargetRef.current = captureTarget; + columnDragCapturePointerIdRef.current = pointerId; + if (captureTarget) { + try { + // Capture on the stable grid parent, never on a column that is + // structurally moved during the live reorder. + captureTarget.setPointerCapture(pointerId); + } catch { + // Window listeners below still handle browsers that reject capture + // after the pointer has already been cancelled. + } + } + columnDragLayoutRef.current = null; + dragGestureRef.current = { + key, + pointerId, + startX: event.clientX, + startY: event.clientY, + active: false, + }; + const pointerMove = (pointerEvent: PointerEvent) => { + if (pointerEvent.pointerId !== pointerId) return; + handleColumnPointerMove( + key, + pointerEvent.pointerId, + pointerEvent.clientX, + pointerEvent.clientY, + () => pointerEvent.preventDefault() + ); + }; + const pointerUp = (pointerEvent: PointerEvent) => { + if (pointerEvent.pointerId === pointerId) { + finishColumnDrag(key, pointerEvent.pointerId); + } + }; + const pointerCancel = (pointerEvent: PointerEvent) => { + if (pointerEvent.pointerId === pointerId) { + finishColumnDrag(key, pointerEvent.pointerId, true); + } + }; + const lostPointerCapture = (pointerEvent: PointerEvent) => { + if (pointerEvent.pointerId === pointerId) { + finishColumnDrag(key, pointerEvent.pointerId, true); + } + }; + window.addEventListener('pointermove', pointerMove); + window.addEventListener('pointerup', pointerUp); + window.addEventListener('pointercancel', pointerCancel); + captureTarget?.addEventListener('lostpointercapture', lostPointerCapture); + columnDragCleanupRef.current = () => { + window.removeEventListener('pointermove', pointerMove); + window.removeEventListener('pointerup', pointerUp); + window.removeEventListener('pointercancel', pointerCancel); + captureTarget?.removeEventListener('lostpointercapture', lostPointerCapture); + }; + }; + const startColumnResize = (key: DownloadTableColumnKey, event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); @@ -411,6 +509,7 @@ export const DownloadTable: React.FC = ({ filter }) => { const alignments = { ...DEFAULT_COLUMN_ALIGNMENTS }; setColumnWidths(widths); columnWidthsRef.current = widths; + columnOrderRef.current = order; setColumnOrder(order); setColumnAlignments(alignments); persistColumnWidths(widths); @@ -440,6 +539,16 @@ export const DownloadTable: React.FC = ({ filter }) => { useEffect(() => () => { resizeCleanupRef.current?.(); + columnDragCleanupRef.current?.(); + columnDragCleanupRef.current = null; + const captureTarget = columnDragCaptureTargetRef.current; + const capturePointerId = columnDragCapturePointerIdRef.current; + columnDragTargetRef.current = null; + columnDragCaptureTargetRef.current = null; + columnDragCapturePointerIdRef.current = null; + if (captureTarget && capturePointerId !== null && captureTarget.hasPointerCapture(capturePointerId)) { + captureTarget.releasePointerCapture(capturePointerId); + } if (columnDropFlashTimerRef.current !== null) { window.clearTimeout(columnDropFlashTimerRef.current); } @@ -448,9 +557,21 @@ export const DownloadTable: React.FC = ({ filter }) => { useEffect(() => { const cancelColumnDrag = () => { if (!dragGestureRef.current) return; + columnDragCleanupRef.current?.(); + columnDragCleanupRef.current = null; + const captureTarget = columnDragCaptureTargetRef.current; + const capturePointerId = columnDragCapturePointerIdRef.current; + columnDragTargetRef.current = null; + columnDragCaptureTargetRef.current = null; + columnDragCapturePointerIdRef.current = null; + if (captureTarget && capturePointerId !== null && captureTarget.hasPointerCapture(capturePointerId)) { + captureTarget.releasePointerCapture(capturePointerId); + } dragGestureRef.current = null; columnDragStateRef.current = null; columnDragOrderRef.current = null; + columnDragLayoutRef.current = null; + setHeaderAnimationEnabled(true); setColumnDragState(null); setColumnDragOrder(null); suppressHeaderClickRef.current = false; @@ -923,10 +1044,7 @@ export const DownloadTable: React.FC = ({ filter }) => {
{ - headerContainerRef.current = element; - headerAnimationParent(element); - }} + ref={headerRef} className={`download-table-header ${columnDragState ? 'is-column-dragging' : ''}`} style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidthWithPadding }} > @@ -958,10 +1076,6 @@ export const DownloadTable: React.FC = ({ filter }) => { openColumnMenu(key, event.clientX, event.clientY); }} onPointerDown={event => handleColumnPointerDown(key, event)} - onPointerMove={event => handleColumnPointerMove(key, event)} - onPointerUp={event => finishColumnDrag(key, event)} - onPointerCancel={event => finishColumnDrag(key, event, true)} - onLostPointerCapture={event => finishColumnDrag(key, event, true)} >