diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c088075..da9fb8a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1520,25 +1520,83 @@ async fn cleanup_media_artifacts(out_path: &std::path::Path, remove_primary: boo let Some(name) = path.file_name().and_then(|name| name.to_str()) else { continue; }; - if !name.starts_with(base_name) && !name.starts_with(base_stem) { - continue; - } - let yt_dlp_format_fragment = name - .strip_prefix(base_stem) - .and_then(|suffix| suffix.strip_prefix(".f")) - .and_then(|suffix| suffix.chars().next()) - .is_some_and(|ch| ch.is_ascii_digit()); - let looks_like_media_temp = name.contains(".part") - || name.contains(".ytdl") - || name.contains(".temp") - || name.contains(".tmp") - || yt_dlp_format_fragment; - if looks_like_media_temp { + if is_media_artifact_name(name, base_name, base_stem) { remove_file_best_effort_with_retry(&path).await; } } } +fn is_media_artifact_name(name: &str, base_name: &str, base_stem: &str) -> bool { + let suffix = name.strip_prefix(base_name).or_else(|| { + (base_stem != base_name) + .then(|| name.strip_prefix(base_stem)) + .flatten() + }); + let Some(suffix) = suffix else { + return false; + }; + + if matches!(suffix, ".part" | ".ytdl" | ".temp" | ".tmp") { + return true; + } + + for marker in [".part", ".ytdl", ".temp", ".tmp"] { + if let Some(extension) = suffix.strip_suffix(marker) { + if is_known_media_extension(extension.strip_prefix('.').unwrap_or(extension)) { + return true; + } + } + } + + let Some(format_suffix) = suffix.strip_prefix(".f") else { + return false; + }; + let Some((format_id, extension)) = format_suffix.split_once('.') else { + return false; + }; + if format_id.is_empty() || !format_id.chars().all(|ch| ch.is_ascii_digit()) { + return false; + } + + let extension = [".part", ".ytdl", ".temp", ".tmp"] + .iter() + .find_map(|marker| extension.strip_suffix(marker)) + .unwrap_or(extension); + + is_known_media_extension(extension) +} + +fn is_known_media_extension(extension: &str) -> bool { + matches!( + extension.to_ascii_lowercase().as_str(), + "3gp" + | "aac" + | "ass" + | "avi" + | "flac" + | "flv" + | "jpg" + | "jpeg" + | "m4a" + | "m4v" + | "mka" + | "mkv" + | "mov" + | "mp3" + | "mp4" + | "oga" + | "ogg" + | "opus" + | "srt" + | "ts" + | "wav" + | "webm" + | "webp" + | "wmv" + | "vtt" + ) +} + fn sanitize_ytdlp_config_value(value: &str) -> String { value.replace(['\n', '\r'], "") } @@ -5095,16 +5153,50 @@ async fn wait_for_aria2_stopped(port: u16, secret: &str, gid: &str) -> Result<() )) } +static NEXT_DOCK_BADGE_SESSION: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(1); + +fn should_apply_dock_badge_update( + current_session: u64, + current_generation: u64, + session: u64, + generation: u64, +) -> bool { + session > current_session || (session == current_session && generation >= current_generation) +} + +#[tauri::command] +fn begin_dock_badge_session() -> u64 { + NEXT_DOCK_BADGE_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + #[tauri::command] #[allow(unused_variables)] -fn update_dock_badge(app_handle: tauri::AppHandle, count: i32) { +fn update_dock_badge( + app_handle: tauri::AppHandle, + count: i32, + generation: u64, + session: u64, +) { #[cfg(target_os = "macos")] { use objc::runtime::Object; use objc::{class, msg_send, sel, sel_impl}; use std::ffi::CString; + use std::sync::{Mutex, OnceLock}; + + static LAST_DOCK_BADGE_STATE: OnceLock> = OnceLock::new(); let _ = app_handle.run_on_main_thread(move || { + let state = LAST_DOCK_BADGE_STATE.get_or_init(|| Mutex::new((0, 0))); + let Ok(mut state) = state.lock() else { + return; + }; + if !should_apply_dock_badge_update(state.0, state.1, session, generation) { + return; + } + *state = (session, generation); + drop(state); unsafe { let app_class = class!(NSApplication); let app: *mut Object = msg_send![app_class, sharedApplication]; @@ -6489,7 +6581,9 @@ mod tests { normalize_speed_limit_for_aria2, parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line, redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value, - has_resumable_download_assets, should_cleanup_media_artifacts_after_failure, + has_resumable_download_assets, is_media_artifact_name, + should_cleanup_media_artifacts_after_failure, + should_apply_dock_badge_update, should_retry_without_browser_cookies, retry_metadata_with_cookies, should_retry_metadata_with_cookies, should_send_metadata_credentials, collect_log_files, FirelinkDeepLink, @@ -7274,6 +7368,29 @@ mod tests { )); } + #[test] + fn media_cleanup_requires_exact_artifact_boundaries() { + assert!(is_media_artifact_name("video.mp4.part", "video.mp4", "video")); + assert!(is_media_artifact_name("video.mp4.tmp", "video.mp4", "video")); + assert!(is_media_artifact_name("video.f137.mp4", "video.mp4", "video")); + assert!(is_media_artifact_name("video.f137.mp4.part", "video.mp4", "video")); + assert!(is_media_artifact_name("video.vtt.part", "video.mp4", "video")); + assert!(is_media_artifact_name("video.jpg.tmp", "video.mp4", "video")); + assert!(!is_media_artifact_name("video.part1.rar", "video.mp4", "video")); + assert!(!is_media_artifact_name("video.mp4.part1.rar", "video.mp4", "video")); + assert!(!is_media_artifact_name("videography.mp4.part", "video.mp4", "video")); + assert!(!is_media_artifact_name("video.f1-backup.tar.gz", "video.mp4", "video")); + assert!(!is_media_artifact_name("video.f1.backup", "video.mp4", "video")); + } + + #[test] + fn dock_badge_updates_reject_stale_sessions_and_generations() { + assert!(should_apply_dock_badge_update(1, 99, 2, 1)); + assert!(should_apply_dock_badge_update(2, 1, 2, 2)); + assert!(!should_apply_dock_badge_update(2, 2, 2, 1)); + assert!(!should_apply_dock_badge_update(2, 2, 1, 99)); + } + #[test] fn metadata_filename_prefers_content_disposition_filename() { assert_eq!( @@ -9035,7 +9152,7 @@ pub fn run() { get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status, get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, pause_download, resume_download, fetch_metadata, fetch_media_metadata, fetch_media_playlist_metadata, - update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, get_free_space, perform_system_action, + begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, get_free_space, perform_system_action, ack_schedule_trigger, check_automation_permission, request_automation_permission, open_automation_settings, set_keychain_password, get_keychain_password, delete_keychain_password, diff --git a/src/App.tsx b/src/App.tsx index b641066..a92819d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { isPermissionGranted, requestPermission, sendNotification } from '@tauri import { WindowControls } from "./components/WindowControls"; import { useToast } from "./contexts/ToastContext"; import { setLogStreamActive } from './utils/logger'; +import { updateDockBadge } from './utils/dockBadge'; import { openUrl } from '@tauri-apps/plugin-opener'; import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform'; import { @@ -207,6 +208,7 @@ function App() { const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks); const showNotifications = useSettingsStore(state => state.showNotifications); const showDockBadge = useSettingsStore(state => state.showDockBadge); + const dockBadgeSyncVersion = useSettingsStore(state => state.dockBadgeSyncVersion); const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon); const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken); const showKeychainModal = useSettingsStore(state => state.showKeychainModal); @@ -689,9 +691,9 @@ function App() { useEffect(() => { if (platform.os === 'macos') { - invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {}); + updateDockBadge(showDockBadge ? activeDownloadCount : 0).catch(() => {}); } - }, [platform.os, showDockBadge, activeDownloadCount]); + }, [platform.os, showDockBadge, dockBadgeSyncVersion, activeDownloadCount]); useEffect(() => { invoke('set_prevent_sleep', { diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index fa4d827..c7febf1 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -92,6 +92,22 @@ const upsertEngineStatus = (items: EngineStatusItem[], item: EngineStatusItem) = return next; }; +const commitBoundedIntegerInput = ( + raw: string, + fallback: number, + min: number, + max: number, + setValue: (value: number) => void, + setDraft: (value: string) => void +) => { + const parsed = Number(raw); + const next = Number.isFinite(parsed) + ? Math.min(max, Math.max(min, Math.trunc(parsed))) + : fallback; + setValue(next); + setDraft(String(next)); +}; + const USER_AGENT_SUGGESTIONS = [ { label: 'Chrome (Windows)', @@ -293,6 +309,25 @@ const engineRunId = useRef(0); const [appVersion, setAppVersion] = useState(''); const [extensionServerPort, setExtensionServerPort] = useState(null); const [systemProxyStatus, setSystemProxyStatus] = useState('idle'); + const [perServerConnectionsInput, setPerServerConnectionsInput] = useState( + () => String(settings.perServerConnections) + ); + const [maxConcurrentDownloadsInput, setMaxConcurrentDownloadsInput] = useState( + () => String(settings.maxConcurrentDownloads) + ); + const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort)); + + useEffect(() => { + setPerServerConnectionsInput(String(settings.perServerConnections)); + }, [settings.perServerConnections]); + + useEffect(() => { + setMaxConcurrentDownloadsInput(String(settings.maxConcurrentDownloads)); + }, [settings.maxConcurrentDownloads]); + + useEffect(() => { + setProxyPortInput(String(settings.proxyPort)); + }, [settings.proxyPort]); // Local state for adding site login const [loginPattern, setLoginPattern] = useState(''); @@ -710,13 +745,22 @@ runEngineChecks(false); settings.setPerServerConnections(Number(e.target.value))} - onBlur={(e) => { - const val = Number(e.target.value); - if (val < 1) settings.setPerServerConnections(1); - if (val > 16) settings.setPerServerConnections(16); + value={perServerConnectionsInput} + onChange={(e) => { + const value = e.target.value; + setPerServerConnectionsInput(value); + if (value !== '' && Number.isFinite(Number(value))) { + settings.setPerServerConnections(Number(value)); + } }} + onBlur={(e) => commitBoundedIntegerInput( + e.target.value, + settings.perServerConnections, + 1, + 16, + settings.setPerServerConnections, + setPerServerConnectionsInput + )} className="app-control w-24 text-center" /> @@ -727,13 +771,22 @@ runEngineChecks(false); settings.setMaxConcurrentDownloads(Number(e.target.value))} - onBlur={(e) => { - const val = Number(e.target.value); - if (val < 1) settings.setMaxConcurrentDownloads(1); - if (val > 12) settings.setMaxConcurrentDownloads(12); + value={maxConcurrentDownloadsInput} + onChange={(e) => { + const value = e.target.value; + setMaxConcurrentDownloadsInput(value); + if (value !== '' && Number.isFinite(Number(value))) { + settings.setMaxConcurrentDownloads(Number(value)); + } }} + onBlur={(e) => commitBoundedIntegerInput( + e.target.value, + settings.maxConcurrentDownloads, + 1, + 12, + settings.setMaxConcurrentDownloads, + setMaxConcurrentDownloadsInput + )} className="app-control w-24 text-center" /> @@ -985,8 +1038,22 @@ runEngineChecks(false); settings.setProxyPort(Number(e.target.value))} + value={proxyPortInput} + onChange={(e) => { + const value = e.target.value; + setProxyPortInput(value); + if (value !== '' && Number.isFinite(Number(value))) { + settings.setProxyPort(Number(value)); + } + }} + onBlur={(e) => commitBoundedIntegerInput( + e.target.value, + settings.proxyPort, + 1, + 65535, + settings.setProxyPort, + setProxyPortInput + )} className="app-control settings-port-input text-center" /> diff --git a/src/ipc.ts b/src/ipc.ts index fdd5562..83dc9c6 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -40,7 +40,8 @@ type CommandMap = { resume_download: { args: { id: string }; result: boolean }; remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void }; detach_download_for_reconfigure: { args: { id: string }; result: void }; - update_dock_badge: { args: { count: number }; result: void }; + begin_dock_badge_session: { args: undefined; result: number }; + update_dock_badge: { args: { count: number; generation: number; session: number }; result: void }; get_platform_info: { args: undefined; result: PlatformInfo }; approve_download_root: { args: { path: string }; result: string }; set_prevent_sleep: { args: { prevent: boolean }; result: void }; diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 2149452..d6ec350 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -14,6 +14,7 @@ import { resolveCategoryDestination } from '../utils/downloadLocations'; import { canPauseDownload, canStartDownload } from '../utils/downloadActions'; +import { updateDockBadge } from '../utils/dockBadge'; import i18n from '../i18n'; export type { DownloadCategory } from '../utils/downloads'; @@ -487,7 +488,7 @@ export const getSiteLogin = (url: string, settings: ReturnType { const settings = useSettingsStore.getState(); const activeCount = useDownloadStore.getState().downloads.filter(d => isTransferActiveStatus(d.status)).length; - invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {}); + updateDockBadge(settings.showDockBadge ? activeCount : 0).catch(() => {}); }; const effectiveDestinationForItem = async ( diff --git a/src/store/useSettingsStore.test.ts b/src/store/useSettingsStore.test.ts index 41360b8..244c467 100644 --- a/src/store/useSettingsStore.test.ts +++ b/src/store/useSettingsStore.test.ts @@ -37,6 +37,19 @@ describe('useSettingsStore global speed limit persistence', () => { }); }); +describe('useSettingsStore dock badge synchronization', () => { + it('increments the badge sync version for every toggle without issuing out-of-band clears', () => { + vi.clearAllMocks(); + const initialVersion = useSettingsStore.getState().dockBadgeSyncVersion; + + useSettingsStore.getState().setShowDockBadge(false); + useSettingsStore.getState().setShowDockBadge(true); + + expect(useSettingsStore.getState().dockBadgeSyncVersion).toBe(initialVersion + 2); + expect(ipc.invokeCommand).not.toHaveBeenCalledWith('update_dock_badge', { count: 0 }); + }); +}); + describe('useSettingsStore credential-store startup flow', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 110a8aa..0b7a67a 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -203,6 +203,8 @@ export interface SettingsState { appFontSize: AppFontSize; listRowDensity: ListRowDensity; showDockBadge: boolean; + /** Forces the App-level badge effect to run for every toggle request. */ + dockBadgeSyncVersion: number; showMenuBarIcon: boolean; proxyMode: ProxyMode; proxyHost: string; @@ -320,6 +322,7 @@ export const useSettingsStore = create()( appFontSize: 'standard', listRowDensity: 'standard', showDockBadge: true, + dockBadgeSyncVersion: 0, showMenuBarIcon: true, proxyMode: 'none', proxyHost: '', @@ -392,8 +395,10 @@ export const useSettingsStore = create()( setAppFontSize: (appFontSize) => set({ appFontSize }), setListRowDensity: (listRowDensity) => set({ listRowDensity }), setShowDockBadge: (showDockBadge) => { - set({ showDockBadge }); - if (!showDockBadge) invoke('update_dock_badge', { count: 0 }).catch(console.error); + set(state => ({ + showDockBadge, + dockBadgeSyncVersion: state.dockBadgeSyncVersion + 1 + })); }, setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }), setProxyMode: (proxyMode) => set({ proxyMode }), diff --git a/src/utils/dockBadge.test.ts b/src/utils/dockBadge.test.ts new file mode 100644 index 0000000..74d9efe --- /dev/null +++ b/src/utils/dockBadge.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as ipc from '../ipc'; +import { updateDockBadge } from './dockBadge'; + +vi.mock('../ipc', () => ({ + invokeCommand: vi.fn() +})); + +describe('dock badge synchronization', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ipc.invokeCommand).mockImplementation(async command => ( + command === 'begin_dock_badge_session' ? 1 : undefined + )); + }); + + it('attaches increasing generations to concurrent updates', async () => { + await Promise.all([updateDockBadge(3), updateDockBadge(0)]); + + const calls = vi.mocked(ipc.invokeCommand).mock.calls; + expect(calls).toHaveLength(3); + expect(calls[0]).toEqual(['begin_dock_badge_session']); + const badgeCalls = calls.slice(1); + expect(badgeCalls[0]).toEqual([ + 'update_dock_badge', + { count: 3, generation: expect.any(Number), session: 1 } + ]); + expect(badgeCalls[1]).toEqual([ + 'update_dock_badge', + { count: 0, generation: expect.any(Number), session: 1 } + ]); + const firstBadgeArgs = badgeCalls[0][1] as { generation: number }; + const secondBadgeArgs = badgeCalls[1][1] as { generation: number }; + expect(secondBadgeArgs.generation).toBe(firstBadgeArgs.generation + 1); + }); +}); diff --git a/src/utils/dockBadge.ts b/src/utils/dockBadge.ts new file mode 100644 index 0000000..fb1acff --- /dev/null +++ b/src/utils/dockBadge.ts @@ -0,0 +1,25 @@ +import { invokeCommand } from '../ipc'; + +let dockBadgeGeneration = 0; +let dockBadgeSessionRequest: Promise | null = null; + +const getDockBadgeSession = (): Promise => { + if (!dockBadgeSessionRequest) { + const request = invokeCommand('begin_dock_badge_session'); + dockBadgeSessionRequest = request.catch(error => { + dockBadgeSessionRequest = null; + throw error; + }); + } + return dockBadgeSessionRequest; +}; + +/** + * Attach the backend session and a per-session generation to every badge + * update so stale main-thread callbacks cannot overwrite a newer session. + */ +export const updateDockBadge = async (count: number): Promise => { + const session = await getDockBadgeSession(); + const generation = ++dockBadgeGeneration; + await invokeCommand('update_dock_badge', { count, generation, session }); +};