From 9d1e8d994aafe2d56eaaf60c4891bfcea3657b84 Mon Sep 17 00:00:00 2001 From: NimBold Date: Fri, 28 Aug 2026 05:07:54 +0330 Subject: [PATCH] fix(windows): repair properties chrome and credential recovery Refs #37. - Keep the transparent Properties window renderer-owned at its rounded corners without native shadow bleed. - Keep the complete custom caption-control rail outside Tauri drag hit-testing so every button area receives clicks. - Retry credential-marked downloads through a fresh lifecycle with restored keychain credentials or a safe credentialless request. - Preserve keychain consent boundaries, sanitize unavailable request credentials, and retain retryable errors when recovery fails. - Remove the obsolete manual credentialless retry confirmation and keep all start/resume entry points consistent. --- src-tauri/src/properties_window.rs | 6 +- src/components/AddDownloadsModal.tsx | 12 +- src/components/DownloadItem.tsx | 13 +- src/components/DownloadTable.tsx | 46 +-- src/components/PropertiesWindowApp.tsx | 28 +- src/components/PropertiesWindowBridgeHost.tsx | 13 +- src/components/Sidebar.tsx | 16 +- src/components/WindowControls.tsx | 7 +- src/i18n/catalogs/en.ts | 3 - src/i18n/catalogs/fa.ts | 3 - src/i18n/catalogs/he.ts | 3 - src/i18n/catalogs/ru.ts | 3 - src/i18n/catalogs/uk.ts | 3 - src/i18n/catalogs/zh-CN.ts | 3 - src/index.css | 15 + src/propertiesBridge.ts | 4 +- src/store/downloadStore.ts | 14 +- src/store/useDownloadStore.test.ts | 295 +++++++++++--- src/store/useDownloadStore.ts | 382 +++++++++--------- 19 files changed, 503 insertions(+), 366 deletions(-) diff --git a/src-tauri/src/properties_window.rs b/src-tauri/src/properties_window.rs index 13ccfc4..484ede8 100644 --- a/src-tauri/src/properties_window.rs +++ b/src-tauri/src/properties_window.rs @@ -455,7 +455,11 @@ pub async fn open_download_properties_window( // A hidden WebView2 must not request focus during construction. The // native reveal path focuses it after the window is visible. .focused(false) - .transparent(true); + .transparent(true) + // The rounded surface is painted by the child renderer. Tao enables + // its undecorated Windows shadow by default, which leaves an opaque + // native frame outside that renderer surface at the corners. + .shadow(false); #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] let builder = builder.decorations(false); let build_result = builder.build(); diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 8c3deac..2144c87 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -723,7 +723,11 @@ export const AddDownloadsModal = () => { url: row.sourceUrl, cookieBrowser: browserArg, userAgent: settingsStore.customUserAgent.trim() || null, - username: useAuth ? username.trim() || null : login?.username || null, + username: useAuth + ? username.trim() || null + : typeof keychainPassword === 'string' && keychainPassword.trim() + ? login?.username || null + : null, password: useAuth ? password || null : keychainPassword, headers: rowHeaders || null, cookies: rowCookies || null, @@ -827,7 +831,11 @@ export const AddDownloadsModal = () => { const meta = await invoke('fetch_metadata', { url: row.sourceUrl, userAgent: settingsStore.customUserAgent.trim() || null, - username: useAuth ? username.trim() || null : login?.username || null, + username: useAuth + ? username.trim() || null + : typeof keychainPassword === 'string' && keychainPassword.trim() + ? login?.username || null + : null, password: useAuth ? password || null : keychainPassword, headers: headersForRow(contextUrl) || null, cookies: cookiesForRow(contextUrl, row.sourceUrl) || null, diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 88671ff..f2e34da 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -251,9 +251,7 @@ export const DownloadItem = React.memo(({ : waitingForPeers ? t($ => $.downloads.status.waitingForPeers) : t($ => $.downloads.status[download.status]); - const visibleErrorStatusLabel = download.credentialsRequired === true - ? t($ => $.properties.credentialsRequired) - : download.lastErrorKind === 'nameResolution' + const visibleErrorStatusLabel = download.lastErrorKind === 'nameResolution' ? download.status === 'retrying' && download.lastResolverFallback === true ? t($ => $.downloads.errors.nameResolutionRetrying) : download.status === 'failed' @@ -371,7 +369,6 @@ export const DownloadItem = React.memo(({ download.status === 'failed' || download.status === 'retrying' || download.lastErrorKind === 'destinationAccess' - || download.credentialsRequired === true ) ? download.lastError : (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 @@ -487,14 +484,10 @@ export const DownloadItem = React.memo(({ onClick={() => isBulkSelection ? handleResumeSelected() : handleResume(download)} className="app-icon-button main-control-button" title={resumeSelectionCount === null - ? download.credentialsRequired === true - ? t($ => $.properties.retryWithoutCredentials) - : download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start) + ? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start) : `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`} aria-label={resumeSelectionCount === null - ? download.credentialsRequired === true - ? t($ => $.properties.retryWithoutCredentials) - : download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start) + ? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start) : `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`} > diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index bb2ad8e..66d0ecc 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -1878,15 +1878,12 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC try { const current = useDownloadStore.getState().downloads.find(download => download.id === item.id); if (!current) return; - let resumeWithoutCredentials = false; - if (current.credentialsRequired === true) { - resumeWithoutCredentials = window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm)); - if (!resumeWithoutCredentials) return; - } - const resumed = await useDownloadStore.getState().resumeDownload(item.id, { - resumeWithoutCredentials - }); + const resumed = await useDownloadStore.getState().resumeDownload(item.id); if (!resumed) { + // A configured site login opens the keychain consent modal instead of + // starting a credentialless request. That is a pending user decision, + // not a backend rejection, so do not show a second misleading error. + if (useSettingsStore.getState().showKeychainModal) return; const latest = useDownloadStore.getState().downloads.find( download => download.id === item.id ); @@ -1929,42 +1926,13 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC const handleResumeSelected = useCallback(() => { const ids = Array.from(selectedIdsRef.current); if (ids.length === 0) return; - const selected = useDownloadStore.getState().downloads.filter(download => ids.includes(download.id)); - const credentialMarkedIds = selected - .filter(download => download.credentialsRequired === true && canStartDownload(download.status)) - .map(download => download.id); - if (credentialMarkedIds.length > 0 - && !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) { - // Continue ordinary selected resumes. Credential-marked rows remain - // fail-closed and can be handled individually after the user supplies - // credentials or confirms a credentialless retry. - const credentialMarkedIdSet = new Set(credentialMarkedIds); - const ordinaryIds = ids.filter(id => !credentialMarkedIdSet.has(id)); - if (ordinaryIds.length === 0) return; - void startSelected(ordinaryIds).catch(error => { - showInteractionError(t($ => $.downloadTable.resumeFailed), error); - }); - return; - } - void startSelected(ids, { - resumeWithoutCredentialsIds: credentialMarkedIds - }).catch(error => { + void startSelected(ids).catch(error => { showInteractionError(t($ => $.downloadTable.resumeFailed), error); }); }, [showInteractionError, startSelected, t]); const handleStartAll = useCallback(() => { - const credentialMarkedIds = useDownloadStore.getState().downloads - .filter(download => - download.credentialsRequired === true - && (download.status === 'queued' || canStartDownload(download.status)) - ) - .map(download => download.id); - const resumeWithoutCredentials = credentialMarkedIds.length > 0 - && window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm)); - void startAll({ - resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : [] - }).catch(error => { + void startAll().catch(error => { showInteractionError(t($ => $.downloadTable.resumeFailed), error); }); }, [showInteractionError, startAll, t]); diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 9c2a8a3..b55c33b 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -1225,15 +1225,13 @@ export const PropertiesWindowApp = () => { ); const progressPercent = allocationPending ? '—' : `${Math.round(progress * 100)}%`; const statusTone = allocationPending ? 'downloading' : propertiesStatusTone(snapshot.status); - const lifecycleLabel = snapshot.credentialsRequired === true - ? t($ => $.properties.retryWithoutCredentials) - : lifecycleAction === 'pause' - ? t($ => $.downloads.actions.pause) - : lifecycleAction === 'resume' - ? t($ => $.downloads.actions.resume) - : lifecycleAction === 'retry' - ? t($ => $.downloads.actions.retry) - : t($ => $.downloads.actions.start); + const lifecycleLabel = lifecycleAction === 'pause' + ? t($ => $.downloads.actions.pause) + : lifecycleAction === 'resume' + ? t($ => $.downloads.actions.resume) + : lifecycleAction === 'retry' + ? t($ => $.downloads.actions.retry) + : t($ => $.downloads.actions.start); const tabLabel = (tab: PropertiesTab) => { switch (tab) { case 'overview': return t($ => $.properties.tabs.overview); @@ -1278,16 +1276,7 @@ export const PropertiesWindowApp = () => { && !window.confirm(t($ => $.downloadTable.nonResumableOne))) { return; } - const resumeWithoutCredentials = (lifecycleAction === 'resume' || lifecycleAction === 'retry') - && snapshot.credentialsRequired === true; - if (resumeWithoutCredentials - && !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) { - return; - } - void requestAction( - 'pause-resume', - resumeWithoutCredentials ? { resumeWithoutCredentials: true } : undefined, - ); + void requestAction('pause-resume'); }} > {lifecycleAction === 'pause' ? : } @@ -1704,7 +1693,6 @@ export const PropertiesWindowApp = () => { {activeTab === 'advanced' &&

{t($ => $.properties.advancedTransfer)}

- {snapshot.credentialsRequired === true &&

{t($ => $.properties.credentialsRequired)}

} {isSftp && }
{connectionHeaderLabel}

{connectionValue}

diff --git a/src/components/PropertiesWindowBridgeHost.tsx b/src/components/PropertiesWindowBridgeHost.tsx index 9180560..17cac60 100644 --- a/src/components/PropertiesWindowBridgeHost.tsx +++ b/src/components/PropertiesWindowBridgeHost.tsx @@ -538,15 +538,12 @@ export const PropertiesWindowBridgeHost = () => { throw new Error('The download did not reach a paused or terminal state'); } } else { - const resumeWithoutCredentials = typeof request.payload === 'object' - && request.payload !== null - && 'resumeWithoutCredentials' in request.payload - && request.payload.resumeWithoutCredentials === true; - const resumed = await store.resumeDownload( - request.downloadId, - resumeWithoutCredentials ? { resumeWithoutCredentials: true } : undefined, - ); + const resumed = await store.resumeDownload(request.downloadId); if (!resumed) { + // The resume request may have opened the main window's + // keychain consent modal. It is a pending user decision, not + // a backend rejection to report from the child window. + if (useSettingsStore.getState().showKeychainModal) break; throw new Error(i18n.t($ => $.downloadTable.backendRejectedStart)); } // resumeDownload returns after the lifecycle request has been diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index eddcf69..a3455d8 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -7,12 +7,11 @@ import { ChevronDown, type LucideIcon } from 'lucide-react'; -import { useDownloadStore, DownloadCategory, Queue, MAIN_QUEUE_ID } from '../store/useDownloadStore'; +import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore'; import { ActiveView, useSettingsStore } from '../store/useSettingsStore'; import { WindowDragRegion } from './WindowDragRegion'; import { useToast } from '../contexts/ToastContext'; import { isTransferActiveStatus } from '../utils/downloads'; -import { canStartDownload } from '../utils/downloadActions'; import { clampFloatingPosition } from '../utils/floatingPosition'; import { useTranslation } from 'react-i18next'; @@ -525,19 +524,8 @@ export const Sidebar: React.FC = (props) => { className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover" onClick={() => { const queueId = contextMenu.id; - const credentialMarkedIds = downloads - .filter(download => - (download.queueId || MAIN_QUEUE_ID) === queueId - && download.credentialsRequired === true - && (download.status === 'queued' || canStartDownload(download.status)) - ) - .map(download => download.id); - const resumeWithoutCredentials = credentialMarkedIds.length > 0 - && window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm)); setContextMenu(null); - void startQueue(queueId, { - resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : [] - }).catch(error => { + void startQueue(queueId).catch(error => { addToast({ message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }), variant: 'error', diff --git a/src/components/WindowControls.tsx b/src/components/WindowControls.tsx index 748e688..96b52d7 100644 --- a/src/components/WindowControls.tsx +++ b/src/components/WindowControls.tsx @@ -1,12 +1,12 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { Maximize2, Minus, X } from 'lucide-react'; -import type { PointerEvent } from 'react'; +import type { MouseEvent, PointerEvent } from 'react'; import { useTranslation } from 'react-i18next'; import type { ResolvedWindowControlStyle } from '../utils/windowControlStyle'; const appWindow = getCurrentWindow(); -const stopTitlebarDrag = (event: PointerEvent) => { +const stopTitlebarDrag = (event: PointerEvent | MouseEvent) => { event.stopPropagation(); }; @@ -23,6 +23,9 @@ export function WindowControls({ side, controlStyle }: WindowControlsProps) { className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`} aria-label={t($ => $.window.controls)} role="group" + data-tauri-drag-region="false" + onPointerDown={stopTitlebarDrag} + onMouseDown={stopTitlebarDrag} >