From ae9f14ad6eaabbd6596e9eaacb736dbdd5b8e5dc Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 2 Jul 2026 22:38:25 +0330 Subject: [PATCH] fix(settings): harden locations and site login matching --- src/components/SettingsView.tsx | 17 +++++--- src/store/useDownloadStore.test.ts | 21 +++++++++- src/store/useDownloadStore.ts | 64 +++++++++++++++++++++++------ src/utils/downloadLocations.test.ts | 21 +++++++++- src/utils/downloadLocations.ts | 25 +++++++++++ 5 files changed, 128 insertions(+), 20 deletions(-) diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 40c3a92..6f95ada 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -22,7 +22,9 @@ import appIcon from '../assets/app-icon.png'; import { DEFAULT_CATEGORY_SUBFOLDERS, DOWNLOAD_CATEGORIES, - normalizeCategorySubfolder + formatDerivedCategoryPath, + normalizeCategorySubfolder, + subfolderFromDerivedCategoryPath } from '../utils/downloadLocations'; import { usePlatformInfo } from '../utils/platform'; @@ -146,10 +148,10 @@ const CategoryFolderInput = ({ settings: SettingsState; onBrowse: () => void; }) => { - const base = settings.baseDownloadFolder.replace(/\/+$/, '') || '~/Downloads'; + const base = settings.baseDownloadFolder || '~/Downloads'; const sub = settings.categorySubfolders[category] || DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS]; const override = settings.categoryDirectoryOverrides[category]; - const displayPath = override ?? `${base}/${sub}`; + const displayPath = override ?? formatDerivedCategoryPath(base, sub); const [localValue, setLocalValue] = useState(null); @@ -164,17 +166,17 @@ const CategoryFolderInput = ({ onChange={(e) => setLocalValue(e.target.value)} onBlur={() => { const val = localValue ?? displayPath; - const basePrefix = base + '/'; + const derivedSubfolder = subfolderFromDerivedCategoryPath(val, base); if (!val.trim()) { settings.setCategoryDirectoryOverride(category, undefined); settings.setCategorySubfolder(category, ''); - } else if (val.startsWith(basePrefix)) { + } else if (derivedSubfolder !== null) { settings.setCategoryDirectoryOverride(category, undefined); settings.setCategorySubfolder( category, normalizeCategorySubfolder( - val.substring(basePrefix.length), + derivedSubfolder, DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS] ) ); @@ -867,6 +869,9 @@ runEngineChecks(false); type="text" value={settings.baseDownloadFolder} onChange={(e) => settings.setBaseDownloadFolder(e.target.value)} + onBlur={() => { + settings.setBaseDownloadFolder(settings.baseDownloadFolder.trim() || '~/Downloads'); + }} className="app-control w-64 text-[11px] px-2" placeholder="~/Downloads" /> diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 8d7047b..3ae2058 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getProxyArgs, normalizeCustomProxy, useDownloadStore } from './useDownloadStore'; +import { getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore'; import { useSettingsStore } from './useSettingsStore'; import * as ipc from '../ipc'; @@ -90,6 +90,25 @@ describe('useDownloadStore', () => { } as ReturnType)).toBe('socks5://127.0.0.1:1080'); }); + it('matches site logins by host, wildcard host, path, and full URL patterns', () => { + const settings = { + siteLogins: [ + { id: 'host', urlPattern: 'example.com', username: 'host' }, + { id: 'wildcard', urlPattern: '*.cdn.example.com', username: 'wildcard' }, + { id: 'broad', urlPattern: '*.example.com', username: 'broad' }, + { id: 'path', urlPattern: 'secure.example.com/private/*', username: 'path' }, + { id: 'url', urlPattern: 'https://downloads.example.net/releases/*', username: 'url' } + ] + } as ReturnType; + + expect(getSiteLogin('https://example.com/file.zip', settings)?.id).toBe('host'); + expect(getSiteLogin('https://assets.cdn.example.com/file.zip', settings)?.id).toBe('wildcard'); + expect(getSiteLogin('https://secure.example.com/private/file.zip', settings)?.id).toBe('path'); + expect(getSiteLogin('https://downloads.example.net/releases/app.zip', settings)?.id).toBe('url'); + expect(getSiteLogin('https://secure.example.com/public/file.zip', settings)?.id).toBe('broad'); + expect(getSiteLogin('https://unrelated.example.org/public/file.zip', settings)).toBeNull(); + }); + it('Start Queue dispatches exactly once for mixed dispatched/undispatched items', async () => { useDownloadStore.setState({ downloads: [ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index f9e787a..ff42960 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -124,24 +124,64 @@ export const getProxyArgs = async (settings: ReturnType + value.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); + +const wildcardToRegex = (pattern: string): RegExp => + new RegExp(`^${escapeRegex(pattern).replace(/\*+/g, '.*')}$`); + +const patternSpecificity = (pattern: string): number => + pattern.replace(/\*/g, '').length; + +const hostPatternScore = (pattern: string, host: string): number | null => { + if (pattern.startsWith('*.')) { + const suffix = pattern.substring(2); + return host === suffix || host.endsWith(`.${suffix}`) + ? 1000 + patternSpecificity(pattern) + : null; + } + + if (pattern.includes('*')) { + return wildcardToRegex(pattern).test(host) + ? 1000 + patternSpecificity(pattern) + : null; + } + + return host === pattern ? 2000 + patternSpecificity(pattern) : null; +}; + +const urlPatternScore = (pattern: string, url: URL): number | null => { + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(pattern)) { + const normalizedPattern = pattern.toLowerCase().replace(/\/+$/, ''); + const normalizedUrl = url.toString().toLowerCase().replace(/\/+$/, ''); + return wildcardToRegex(normalizedPattern).test(normalizedUrl) + ? 4000 + patternSpecificity(normalizedPattern) + : null; + } + + if (pattern.includes('/')) { + const normalizedPattern = pattern.toLowerCase().replace(/^\/+/, ''); + const normalizedTarget = `${url.hostname}${url.pathname}`.toLowerCase().replace(/\/+$/, ''); + return wildcardToRegex(normalizedPattern).test(normalizedTarget) + ? 3000 + patternSpecificity(normalizedPattern) + : null; + } + + return hostPatternScore(pattern, url.hostname.toLowerCase()); +}; + export const getSiteLogin = (url: string, settings: ReturnType) => { try { const urlObj = new URL(url); - const host = urlObj.hostname.toLowerCase(); + let bestMatch: { login: typeof settings.siteLogins[number]; score: number } | null = null; for (const login of settings.siteLogins) { - let pattern = login.urlPattern.toLowerCase().trim(); - if (pattern.startsWith('*.')) { - const suffix = pattern.substring(2); - if (host === suffix || host.endsWith('.' + suffix)) return login; - } else if (pattern.includes('*')) { - const collapsed = pattern.replace(/\*+/g, '*'); - const escaped = collapsed.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp('^' + escaped.replace(/\*/g, '.*') + '$'); - if (regex.test(host)) return login; - } else if (host === pattern) { - return login; + const pattern = login.urlPattern.toLowerCase().trim(); + const score = pattern ? urlPatternScore(pattern, urlObj) : null; + if (score !== null && (!bestMatch || score > bestMatch.score)) { + bestMatch = { login, score }; } } + return bestMatch?.login ?? null; } catch (e) {} return null; }; diff --git a/src/utils/downloadLocations.test.ts b/src/utils/downloadLocations.test.ts index 3fda862..9e96917 100644 --- a/src/utils/downloadLocations.test.ts +++ b/src/utils/downloadLocations.test.ts @@ -11,9 +11,11 @@ vi.mock('@tauri-apps/api/path', () => ({ import { downloadLocationEquals, DEFAULT_CATEGORY_SUBFOLDERS, + formatDerivedCategoryPath, normalizeCategorySubfolder, normalizeDownloadLocationSettings, - resolveCategoryDestination + resolveCategoryDestination, + subfolderFromDerivedCategoryPath } from './downloadLocations'; describe('download locations', () => { @@ -68,4 +70,21 @@ describe('download locations', () => { expect(normalizeCategorySubfolder('C:\\Media\\Movies', 'Movies')).toBe('Media/Movies'); expect(normalizeCategorySubfolder('../../', 'Movies')).toBe('Movies'); }); + + it('formats and parses derived category paths with platform path separators', () => { + expect(formatDerivedCategoryPath('/Users/test/Downloads', 'Video Files')) + .toBe('/Users/test/Downloads/Video Files'); + expect(formatDerivedCategoryPath('D:\\Downloads', 'Video Files')) + .toBe('D:\\Downloads\\Video Files'); + expect(subfolderFromDerivedCategoryPath( + 'D:\\Downloads\\Video Files', + 'd:\\downloads' + )).toBe('Video Files'); + expect(subfolderFromDerivedCategoryPath( + '/Users/test/Downloads/Video Files', + '/Users/test/Downloads' + )).toBe('Video Files'); + expect(subfolderFromDerivedCategoryPath('/Volumes/Media', '/Users/test/Downloads')) + .toBeNull(); + }); }); diff --git a/src/utils/downloadLocations.ts b/src/utils/downloadLocations.ts index bbe3d45..40b2655 100644 --- a/src/utils/downloadLocations.ts +++ b/src/utils/downloadLocations.ts @@ -61,6 +61,31 @@ const normalizedForComparison = (value: string): string => const legacyDerivedPath = (base: string, subfolder: string): string => `${normalizedForComparison(base)}/${subfolder.replace(/^[\\/]+|[\\/]+$/g, '')}`; +const isWindowsLikePath = (value: string): boolean => + /^[a-z]:[\\/]/i.test(value) || value.startsWith('\\\\') || value.includes('\\'); + +export const formatDerivedCategoryPath = (base: string, subfolder: string): string => { + const trimmedBase = base.trim() || '~/Downloads'; + const relative = subfolder.replace(/^[\\/]+|[\\/]+$/g, ''); + const separator = isWindowsLikePath(trimmedBase) ? '\\' : '/'; + return `${trimmedBase.replace(/[\\/]+$/, '')}${separator}${relative}`; +}; + +export const subfolderFromDerivedCategoryPath = ( + value: string, + base: string +): string | null => { + const normalizedValue = normalizedForComparison(value.trim()); + const normalizedBase = normalizedForComparison((base.trim() || '~/Downloads')); + const isWindows = isWindowsLikePath(base); + const comparableValue = isWindows ? normalizedValue.toLocaleLowerCase() : normalizedValue; + const comparableBase = isWindows ? normalizedBase.toLocaleLowerCase() : normalizedBase; + + if (comparableValue === comparableBase) return ''; + if (!comparableValue.startsWith(`${comparableBase}/`)) return null; + return normalizedValue.slice(normalizedBase.length + 1); +}; + export const normalizeCategorySubfolder = ( value: string, fallback: string