fix: app nap ipc drop and implement delete modal

This commit is contained in:
NimBold
2026-06-15 13:29:20 +03:30
parent d046c95c28
commit 9e26be5b2c
8 changed files with 141 additions and 78 deletions
+14 -1
View File
@@ -18,7 +18,7 @@ use tokio::{
};
use uuid::Uuid;
const PROGRESS_INTERVAL: Duration = Duration::from_millis(150);
const PROGRESS_INTERVAL: Duration = Duration::from_millis(1000);
const WRITE_BUFFER_CAPACITY: usize = 256 * 1024;
#[derive(Debug)]
@@ -153,6 +153,7 @@ impl CoordinatorEventSink {
fraction,
speed: format_speed(speed_bytes),
eta,
size: total.map(|t| format_size(t as f64)),
},
);
}
@@ -586,6 +587,18 @@ fn format_speed(bytes_per_second: f64) -> String {
}
}
fn format_size(bytes: f64) -> String {
if bytes >= 1024.0 * 1024.0 * 1024.0 {
format!("{:.2} GB", bytes / (1024.0 * 1024.0 * 1024.0))
} else if bytes >= 1024.0 * 1024.0 {
format!("{:.1} MB", bytes / (1024.0 * 1024.0))
} else if bytes >= 1024.0 {
format!("{:.1} KB", bytes / 1024.0)
} else {
format!("{bytes:.0} B")
}
}
fn format_duration(seconds: f64) -> String {
if seconds >= 3600.0 {
format!("{:.0}h {:.0}m", seconds / 3600.0, (seconds % 3600.0) / 60.0)
+11 -2
View File
@@ -164,8 +164,17 @@ async fn download_handler(
};
if let Some(window) = state.app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let is_visible = window.is_visible().unwrap_or(true);
if !is_visible {
let _ = window.show();
let _ = window.set_focus();
// Sleep briefly to let the webview wake up from macOS App Nap
// otherwise the IPC event emitted immediately after is dropped.
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
if state.app_handle.emit("extension-add-download", download).is_err() {
+3 -1
View File
@@ -367,6 +367,7 @@ pub struct DownloadProgressEvent {
fraction: f64,
speed: String,
eta: String,
size: Option<String>,
}
fn collect_download_uris(url: &str, mirrors: Option<&str>) -> Vec<String> {
@@ -790,12 +791,13 @@ pub(crate) async fn start_media_download_internal(
.unwrap_or_else(|| "-".to_string());
let now = std::time::Instant::now();
if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(150) {
if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(1000) {
let _ = app_handle.emit("download-progress", DownloadProgressEvent {
id: id.to_string(),
fraction: overall_fraction,
speed,
eta,
size: None,
});
last_progress_at = now;
}
+11 -5
View File
@@ -4,6 +4,7 @@ import { DownloadTable } from "./components/DownloadTable";
import { AddDownloadsModal } from "./components/AddDownloadsModal";
import SettingsView from "./components/SettingsView";
import { PropertiesModal } from "./components/PropertiesModal";
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
import { useSettingsStore } from "./store/useSettingsStore";
@@ -182,15 +183,19 @@ function App() {
}, [theme]);
useEffect(() => {
const unlistenProgress = listen('download-progress', (event) => {
const { id, fraction, speed, eta } = event.payload;
const unlistenProgress = listen('download-progress', (event: any) => {
const { id, fraction, speed, eta, size } = event.payload;
const state = useDownloadStore.getState();
const current = state.downloads.find(d => d.id === id);
const updates: any = { fraction, speed, eta };
if (size) updates.size = size;
if (current && current.status === 'queued') {
updateDownload(id, { status: 'downloading', fraction, speed, eta });
} else {
updateDownload(id, { fraction, speed, eta });
updates.status = 'downloading';
}
updateDownload(id, updates);
});
const unlistenComplete = listen('download-complete', (event) => {
@@ -285,6 +290,7 @@ function App() {
<AddDownloadsModal />
<PropertiesModal />
<DeleteConfirmationModal />
</div>
);
}
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, };
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, };
@@ -0,0 +1,73 @@
import React from 'react';
import { useDownloadStore } from '../store/useDownloadStore';
import { AlertTriangle } from 'lucide-react';
export const DeleteConfirmationModal: React.FC = () => {
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
if (!deleteModalState.isOpen) return null;
const handleCancel = () => {
closeDeleteModal();
};
const handleRemoveFromList = () => {
if (deleteModalState.downloadId) {
removeDownload(deleteModalState.downloadId, false);
}
closeDeleteModal();
};
const handleDeleteFile = () => {
if (deleteModalState.downloadId) {
removeDownload(deleteModalState.downloadId, true);
}
closeDeleteModal();
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 animate-fade-in">
<div
className="bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-5 py-4 border-b border-border-modal flex items-center gap-3">
<div className="p-2 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertTriangle size={20} className="text-red-400" />
</div>
<h2 className="text-lg font-semibold text-text-primary m-0">
Remove Download
</h2>
</div>
{/* Body */}
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed">
Are you sure you want to remove this item from the list? You can also choose to delete the underlying file from your hard drive.
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
<button
onClick={handleCancel}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary"
>
Cancel
</button>
<button
onClick={handleRemoveFromList}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-border-modal hover:bg-border-modal/80 text-text-primary"
>
Remove
</button>
<button
onClick={handleDeleteFile}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-red-500/20 text-red-400 hover:bg-red-500/30"
>
Delete file
</button>
</div>
</div>
</div>
);
};
+4 -17
View File
@@ -11,7 +11,7 @@ interface DownloadTableProps {
}
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const { downloads, toggleAddModal, updateDownload, removeDownload, clearFinished, redownload } = useDownloadStore();
const { downloads, toggleAddModal, updateDownload, openDeleteModal, redownload } = useDownloadStore();
const { isSidebarVisible, toggleSidebar } = useSettingsStore();
const isMac = navigator.userAgent.includes('Mac');
@@ -104,12 +104,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
useDownloadStore.getState().processQueue();
};
const handleDelete = async (id: string) => {
try {
await removeDownload(id);
} catch (e) {
console.error("Failed to delete download:", e);
}
const handleDelete = (id: string) => {
openDeleteModal(id);
};
const contextItem = contextMenu ? downloads.find(d => d.id === contextMenu.id) : null;
@@ -168,15 +164,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
>
<Pause size={15} fill="currentColor" />
</button>
<button
className="main-control-button hover:!text-red-400"
disabled={filteredDownloads.length === 0}
onClick={clearFinished}
title="Clear Finished"
>
<Trash2 size={15} />
</button>
</div>
</div>
@@ -428,7 +415,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}}
className="w-full text-left px-3 py-2 text-red-400 hover:bg-red-500/10 transition-colors"
>
Remove from List
Remove
</button>
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
+24 -51
View File
@@ -59,6 +59,11 @@ export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
export type { DownloadItem, Queue };
export type ExtensionDownloadRequest = ExtensionDownload;
export type DeleteModalState = {
isOpen: boolean;
downloadId?: string;
};
interface DownloadState {
downloads: DownloadItem[];
queues: Queue[];
@@ -70,11 +75,13 @@ interface DownloadState {
toggleAddModal: (isOpen: boolean) => void;
openAddModalWithUrls: (urls: string, referer?: string | null, filename?: string | null) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => void;
deleteModalState: DeleteModalState;
openDeleteModal: (downloadId?: string) => void;
closeDeleteModal: () => void;
setSelectedPropertiesDownloadId: (id: string | null) => void;
addDownload: (item: DownloadItem) => void;
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
removeDownload: (id: string) => Promise<void>;
clearFinished: () => void;
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
redownload: (id: string) => void;
processQueue: () => Promise<void>;
startQueue: (queueId: string) => Promise<number>;
@@ -93,6 +100,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingAddReferer: '',
pendingAddFilename: '',
selectedPropertiesDownloadId: null,
deleteModalState: { isOpen: false },
openDeleteModal: (downloadId) => set({ deleteModalState: { isOpen: true, downloadId } }),
closeDeleteModal: () => set({ deleteModalState: { isOpen: false } }),
toggleAddModal: (isOpen) => set({
isAddModalOpen: isOpen,
pendingAddUrls: '',
@@ -113,44 +123,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
if (urls.length === 0) return;
const settings = useSettingsStore.getState();
if (!request.silent || settings.askWhereToSaveEachFile) {
get().openAddModalWithUrls(
urls.join('\n'),
request.referer,
urls.length === 1 ? request.filename : null
);
return;
}
const referer = request.referer?.trim();
const headers = referer ? `Referer: ${referer}` : undefined;
const dateAdded = new Date().toISOString();
const downloads = urls.map((url, index): DownloadItem => {
const fileName = index === 0 && urls.length === 1 && request.filename?.trim()
? request.filename.trim()
: fileNameFromUrl(url);
return {
id: crypto.randomUUID(),
url,
fileName,
status: 'queued',
category: categoryForFileName(fileName),
dateAdded,
connections: settings.perServerConnections,
headers,
isMedia: isMediaUrl(url),
queueId: MAIN_QUEUE_ID
};
});
set(state => ({ downloads: [...state.downloads, ...downloads] }));
downloads.forEach(item => {
const toSave = { ...item };
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
invoke('db_save_download', { id: item.id, status: item.status, queueId: item.queueId, data: JSON.stringify(toSave) }).catch(console.error);
});
void get().processQueue();
get().openAddModalWithUrls(
urls.join('\n'),
request.referer,
urls.length === 1 ? request.filename : null
);
},
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
addDownload: (item) => {
@@ -191,7 +168,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
syncSystemIntegrations();
}
},
removeDownload: async (id) => {
removeDownload: async (id, deleteFile = false) => {
const item = get().downloads.find(d => d.id === id);
if (item && item.status === 'downloading') {
try {
@@ -199,6 +176,12 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
} catch (e) {
console.error("Failed to terminate download on deletion:", e);
}
} else if (item && deleteFile) {
try {
await invoke('remove_download', { id, filepath: item.destination || null });
} catch (e) {
console.error("Failed to delete file from disk:", e);
}
}
set((state) => ({
downloads: state.downloads.filter(d => d.id !== id)
@@ -207,16 +190,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
get().processQueue();
syncSystemIntegrations();
},
clearFinished: () => {
const downloads = get().downloads;
const toRemove = downloads.filter(d => ['completed', 'failed'].includes(d.status));
set((state) => ({
downloads: state.downloads.filter(d => !['completed', 'failed'].includes(d.status))
}));
toRemove.forEach(d => {
invoke('db_delete_download', { id: d.id }).catch(console.error);
});
},
redownload: (id) => {
let updatedItem: DownloadItem | null = null;
set((state) => ({