fix: align download path ownership with category destinations

This commit is contained in:
NimBold
2026-06-19 07:36:02 +03:30
parent 1ae2fe7703
commit 8a1619f056
4 changed files with 138 additions and 80 deletions
+27 -10
View File
@@ -94,6 +94,7 @@ fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, S
let store = app_handle
.store("store.bin")
.map_err(|e| format!("Failed to load download ownership data: {e}"))?;
let settings = crate::settings::load_settings(app_handle).ok();
let downloads = store
.get("download_queue")
.map(|value| serde_json::from_value::<Vec<crate::ipc::DownloadItem>>(value.clone()))
@@ -104,8 +105,20 @@ fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, S
Ok(downloads
.into_iter()
.filter_map(|download| {
let category = format!("{:?}", download.category);
let destination = download
.destination
.filter(|destination| !destination.trim().is_empty())
.or_else(|| {
settings
.as_ref()
.and_then(|settings| settings.download_directories.get(&category).cloned())
})
.or_else(|| {
settings
.as_ref()
.map(|settings| settings.default_download_path.clone())
})
.unwrap_or_else(|| "~/Downloads".to_string());
let filename = Path::new(&download.file_name.replace('\\', "/"))
.file_name()?
@@ -223,20 +236,24 @@ mod tests {
assert!(
authorize_exact_path(Path::new("owned.bin"), std::slice::from_ref(&owned)).is_err()
);
assert!(authorize_exact_path(
&root.path().join("sub/../owned.bin"),
std::slice::from_ref(&owned)
)
.is_err());
assert!(
authorize_exact_path(
&root.path().join("sub/../owned.bin"),
std::slice::from_ref(&owned)
)
.is_err()
);
assert!(
authorize_exact_path(Path::new("/etc/hosts"), std::slice::from_ref(&owned)).is_err()
);
if let Some(home) = std::env::var_os("HOME") {
assert!(authorize_exact_path(
&PathBuf::from(home).join(".ssh"),
std::slice::from_ref(&owned)
)
.is_err());
assert!(
authorize_exact_path(
&PathBuf::from(home).join(".ssh"),
std::slice::from_ref(&owned)
)
.is_err()
);
}
}
+83 -62
View File
@@ -274,10 +274,12 @@ export const AddDownloadsModal = () => {
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
const [showingDuplicates, setShowingDuplicates] = useState(false);
const [pendingStartFlag, setPendingStartFlag] = useState(false);
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
const [resolvedLocation, setResolvedLocation] = useState('');
// Right Form
const [saveLocation, setSaveLocation] = useState(defaultDownloadPath);
const [isSaveLocationManual, setIsSaveLocationManual] = useState(false);
const [connections, setConnections] = useState(16);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimit, setSpeedLimit] = useState('1024');
@@ -298,9 +300,11 @@ export const AddDownloadsModal = () => {
useEffect(() => {
if (isAddModalOpen) {
setSaveLocation(defaultDownloadPath);
setIsSaveLocationManual(false);
setUrls(pendingAddUrls || '');
setParsedItems([]);
setSelectedItemIndex(null);
setPendingUseSharedDestination(false);
setSelectedQueueId(queues.find(q => q.isMain)?.id || MAIN_QUEUE_ID);
setUseAuth(false);
setUsername('');
@@ -439,12 +443,12 @@ export const AddDownloadsModal = () => {
if (active) setParsedItems([...updatedItems]);
}
if (active && firstReadyIndex !== null) {
setSelectedItemIndex(firstReadyIndex);
const firstFile = updatedItems[firstReadyIndex].file;
if (firstFile) {
const category = categoryForFileName(firstFile);
const settingsStore = useSettingsStore.getState();
if (active && firstReadyIndex !== null && !isSaveLocationManual) {
setSelectedItemIndex(firstReadyIndex);
const firstFile = updatedItems[firstReadyIndex].file;
if (firstFile) {
const category = categoryForFileName(firstFile);
const settingsStore = useSettingsStore.getState();
const categoryDir = (settingsStore.downloadDirectories && settingsStore.downloadDirectories[category]) || settingsStore.defaultDownloadPath || '~/Downloads';
setSaveLocation(categoryDir);
}
@@ -455,7 +459,7 @@ export const AddDownloadsModal = () => {
active = false;
clearTimeout(timer);
};
}, [urls, pendingAddFilename]);
}, [urls, pendingAddFilename, isSaveLocationManual]);
if (!isAddModalOpen) return null;
@@ -466,16 +470,26 @@ export const AddDownloadsModal = () => {
multiple: false,
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
});
if (selected && typeof selected === 'string') {
setSaveLocation(selected);
}
if (selected && typeof selected === 'string') {
setSaveLocation(selected);
setIsSaveLocationManual(true);
}
} catch (e) {
console.error("Failed to select folder:", e);
}
};
const categoryLocationForFile = (fileName: string) => {
const category = categoryForFileName(fileName);
const settingsStore = useSettingsStore.getState();
return (settingsStore.downloadDirectories && settingsStore.downloadDirectories[category]) ||
settingsStore.defaultDownloadPath ||
'~/Downloads';
};
const handleStart = async (startImmediately: boolean) => {
let finalLocation = saveLocation;
let useSharedDestination = isSaveLocationManual;
const settings = useSettingsStore.getState();
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
try {
@@ -486,6 +500,8 @@ export const AddDownloadsModal = () => {
});
if (selected && typeof selected === 'string') {
finalLocation = selected;
useSharedDestination = true;
setIsSaveLocationManual(true);
} else {
return; // Cancelled
}
@@ -502,25 +518,26 @@ export const AddDownloadsModal = () => {
const item = parsedItems[i];
let finalFile = item.file;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const itemLocation = useSharedDestination ? finalLocation : categoryLocationForFile(finalFile);
const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed');
if (isUrlDupe) {
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
} else {
const fileExistsInStore = store.downloads.some(d => {
const dest = d.destination || settings.defaultDownloadPath || '~/Downloads';
return dest === finalLocation && d.fileName === finalFile && d.status !== 'failed';
});
let fileExistsOnDisk = false;
try {
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` });
} catch (e) {}
const fileExistsInStore = store.downloads.some(d => {
const dest = d.destination || settings.defaultDownloadPath || '~/Downloads';
return dest === itemLocation && d.fileName === finalFile && d.status !== 'failed';
});
let fileExistsOnDisk = false;
try {
const cleanLocation = itemLocation.endsWith('/') ? itemLocation.slice(0, -1) : itemLocation;
fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` });
} catch (e) {}
if (fileExistsInStore || fileExistsOnDisk) {
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', msg: 'File exists at destination' }, resolution: 'rename' });
@@ -529,16 +546,17 @@ export const AddDownloadsModal = () => {
}
if (newConflicts.length > 0) {
setConflicts(newConflicts);
setPendingStartFlag(startImmediately);
setShowingDuplicates(true);
return;
setConflicts(newConflicts);
setPendingStartFlag(startImmediately);
setPendingUseSharedDestination(useSharedDestination);
setShowingDuplicates(true);
return;
}
await executeAddDownloads(startImmediately, finalLocation);
await executeAddDownloads(startImmediately, finalLocation, useSharedDestination);
};
const executeAddDownloads = async (startImmediately: boolean, finalLocation: string, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => {
const executeAddDownloads = async (startImmediately: boolean, finalLocation: string, useSharedDestination: boolean, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => {
let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems];
if (resolutions) {
@@ -551,12 +569,13 @@ export const AddDownloadsModal = () => {
itemsToAdd[idx] = null;
} else if (res.resolution === 'rename') {
let finalFile = item.file;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const itemLocation = useSharedDestination ? finalLocation : categoryLocationForFile(finalFile);
const cleanLocation = itemLocation.endsWith('/') ? itemLocation.slice(0, -1) : itemLocation;
let count = 1;
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -565,11 +584,11 @@ export const AddDownloadsModal = () => {
let exists = true;
while (exists && count < 1000) {
newName = `${base} (${count})${ext}`;
const storeHas = useDownloadStore.getState().downloads.some(d => {
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
return dest === finalLocation && d.fileName === newName && d.status !== 'failed';
});
newName = `${base} (${count})${ext}`;
const storeHas = useDownloadStore.getState().downloads.some(d => {
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
return dest === itemLocation && d.fileName === newName && d.status !== 'failed';
});
let diskHas = false;
try { diskHas = await invoke('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {}
exists = storeHas || diskHas;
@@ -579,19 +598,20 @@ export const AddDownloadsModal = () => {
itemsToAdd[idx] = { ...item, file: newName };
} else if (res.resolution === 'replace') {
let finalFile = item.file;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
const fullPath = `${cleanLocation}/${finalFile}`;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const itemLocation = useSharedDestination ? finalLocation : categoryLocationForFile(finalFile);
const cleanLocation = itemLocation.endsWith('/') ? itemLocation.slice(0, -1) : itemLocation;
const fullPath = `${cleanLocation}/${finalFile}`;
const store = useDownloadStore.getState();
const existingItem = store.downloads.find(d => {
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
return (d.url === item.url || (dest === finalLocation && d.fileName === finalFile)) && d.status !== 'failed';
});
const store = useDownloadStore.getState();
const existingItem = store.downloads.find(d => {
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
return (d.url === item.url || (dest === itemLocation && d.fileName === finalFile)) && d.status !== 'failed';
});
if (existingItem) {
await store.removeDownload(existingItem.id);
@@ -617,14 +637,15 @@ export const AddDownloadsModal = () => {
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
}
}
addDownload({
id,
url: item.url,
fileName: finalFile,
status: startImmediately ? 'queued' : 'paused',
category: categoryForFileName(finalFile),
const category = categoryForFileName(finalFile);
addDownload({
id,
url: item.url,
fileName: finalFile,
status: startImmediately ? 'queued' : 'paused',
category,
dateAdded: new Date().toISOString(),
connections: Number(connections),
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
@@ -636,7 +657,7 @@ export const AddDownloadsModal = () => {
: undefined,
cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined,
destination: finalLocation,
destination: useSharedDestination ? finalLocation : undefined,
isMedia: item.isMedia,
mediaFormatSelector: formatSelector,
queueId: selectedQueueId,
@@ -678,7 +699,7 @@ export const AddDownloadsModal = () => {
conflicts={conflicts}
onConfirm={(resolutions) => {
setShowingDuplicates(false);
executeAddDownloads(pendingStartFlag, resolvedLocation, resolutions);
executeAddDownloads(pendingStartFlag, resolvedLocation, pendingUseSharedDestination, resolutions);
}}
onCancel={() => setShowingDuplicates(false)}
/>
+16 -1
View File
@@ -77,7 +77,12 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const getDownloadPath = async (item: DownloadItem) => {
const fileName = item.fileName?.trim();
if (!fileName) return null;
return resolvePath(item.destination || '~/Downloads', fileName);
const settings = useSettingsStore.getState();
const destination = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
return resolvePath(destination, fileName);
};
const openProperties = (id: string) => {
@@ -85,6 +90,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
};
const openDownloadFile = async (item: DownloadItem) => {
if (item.status !== 'completed') {
openProperties(item.id);
return;
}
const fullPath = await getDownloadPath(item);
if (!fullPath) {
openProperties(item.id);
@@ -100,6 +110,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
};
const revealDownloadFile = async (item: DownloadItem) => {
if (item.status !== 'completed') {
openProperties(item.id);
return;
}
const fullPath = await getDownloadPath(item);
if (!fullPath) {
openProperties(item.id);
+12 -7
View File
@@ -74,6 +74,14 @@ const resolveDownloadPath = async (destination: string, fileName: string) => {
return `${resolvedDestination}${separator}${fileName}`;
};
const effectiveDestinationForItem = (
item: Pick<DownloadItem, 'destination' | 'category'>,
settings: ReturnType<typeof useSettingsStore.getState>
) => item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
@@ -206,10 +214,12 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
addDownload: async (item) => {
info(`Download ${item.id} added to queue`);
set((state) => ({ downloads: [...state.downloads, item] }));
try {
const settings = useSettingsStore.getState();
const destPath = effectiveDestinationForItem(item, settings);
const ownedItem = { ...item, destination: destPath };
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login) {
@@ -219,11 +229,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
console.warn("Could not fetch keychain password for login:", e);
}
}
const destPath = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
const enqueueItem = {
id: item.id,
url: item.url,