fix(settings): harden audited state synchronization

This commit is contained in:
NimBold
2026-07-21 05:05:34 +03:30
parent 621652ae13
commit 69ce2b15ba
9 changed files with 304 additions and 37 deletions
+134 -17
View File
@@ -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<Mutex<(u64, u64)>> = 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,
+4 -2
View File
@@ -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', {
+81 -14
View File
@@ -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<number | null>(null);
const [systemProxyStatus, setSystemProxyStatus] = useState<SystemProxyStatus>('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);
</div>
<input
type="number" min="1" max="16"
value={settings.perServerConnections}
onChange={(e) => 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"
/>
</div>
@@ -727,13 +771,22 @@ runEngineChecks(false);
</div>
<input
type="number" min="1" max="12"
value={settings.maxConcurrentDownloads}
onChange={(e) => 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"
/>
</div>
@@ -985,8 +1038,22 @@ runEngineChecks(false);
</div>
<input
type="number" min="1" max="65535"
value={settings.proxyPort}
onChange={(e) => 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"
/>
</div>
+2 -1
View File
@@ -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 };
+2 -1
View File
@@ -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<typeof useSetting
const syncSystemIntegrations = () => {
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 (
+13
View File
@@ -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();
+7 -2
View File
@@ -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<SettingsState>()(
appFontSize: 'standard',
listRowDensity: 'standard',
showDockBadge: true,
dockBadgeSyncVersion: 0,
showMenuBarIcon: true,
proxyMode: 'none',
proxyHost: '',
@@ -392,8 +395,10 @@ export const useSettingsStore = create<SettingsState>()(
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 }),
+36
View File
@@ -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);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { invokeCommand } from '../ipc';
let dockBadgeGeneration = 0;
let dockBadgeSessionRequest: Promise<number> | null = null;
const getDockBadgeSession = (): Promise<number> => {
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<void> => {
const session = await getDockBadgeSession();
const generation = ++dockBadgeGeneration;
await invokeCommand('update_dock_badge', { count, generation, session });
};