fix: default logs off, suppress webview context menu, sort queue rows, defer keychain access to user action

- Disable log capture by default in both backend (LOG_PAUSED=true) and
  frontend (isPaused=true) to avoid unnecessary disk I/O and battery drain.
- Add a global contextmenu listener that prevents the webview's default
  right-click menu (Reload, etc.) so the app behaves like a native macOS
  window. Custom context menus in DownloadItem, LogsView, and Sidebar
  still work because their handlers preventDefault() before the document
  listener fires.
- Sort the download table by queuePosition when viewing a specific queue
  so the move-up/down controls produce a visible reorder instead of a
  silent position swap with no UI feedback.
- Rework the keychain access flow to eliminate the OS credential prompt
  that appeared before the app's own KeychainPermissionModal after every
  binary update. hydrate_extension_pairing_token now always skips the
  keychain; only the explicit Grant Access button (grant_keychain_access)
  triggers the OS prompt, which is user-initiated and therefore acceptable
  even when macOS trust resets after a code-signature change.
This commit is contained in:
NimBold
2026-06-25 02:03:19 +03:30
parent 47373b4565
commit 0a647cd612
6 changed files with 38 additions and 16 deletions
+1
View File
@@ -553,6 +553,7 @@ pub fn load_settings(connection: &Connection) -> Result<Option<String>, String>
.map_err(|error| format!("failed to load settings: {error}")) .map_err(|error| format!("failed to load settings: {error}"))
} }
#[allow(dead_code)]
pub fn is_keychain_access_granted(connection: &Connection) -> Result<bool, String> { pub fn is_keychain_access_granted(connection: &Connection) -> Result<bool, String> {
let Some(settings) = load_settings(connection)? else { let Some(settings) = load_settings(connection)? else {
return Ok(false); return Ok(false);
+7 -2
View File
@@ -3435,7 +3435,12 @@ fn hydrate_extension_pairing_token(
app_state: tauri::State<'_, AppState>, app_state: tauri::State<'_, AppState>,
) -> Result<PairingTokenHydration, String> { ) -> Result<PairingTokenHydration, String> {
let mut connection = database.lock()?; let mut connection = database.lock()?;
let skip_keychain = !crate::db::is_keychain_access_granted(&connection).unwrap_or(false); // Always skip the keychain during automatic hydration so the operating
// system never presents a credential-access prompt before the app's own
// modal is visible. The frontend will display the KeychainPermissionModal
// and only call grant_keychain_access — which touches the keychain — after
// the user explicitly clicks "Grant Access".
let skip_keychain = true;
match crate::db::hydrate_pairing_token(&mut connection, skip_keychain) { match crate::db::hydrate_pairing_token(&mut connection, skip_keychain) {
Ok((token, token_changed)) => { Ok((token, token_changed)) => {
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
@@ -4210,7 +4215,7 @@ mod tests {
} }
} }
static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
#[tauri::command] #[tauri::command]
fn toggle_log_pause(pause: bool) { fn toggle_log_pause(pause: bool) {
+16 -10
View File
@@ -182,6 +182,12 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
default: return d.category === filter; default: return d.category === filter;
} }
}); });
// Sort by queue position when viewing a specific queue so the visual
// order matches the queue order and move-up/down buttons reflect reality.
const sortedDownloads = filter.startsWith('queue:')
? [...filteredDownloads].sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
: filteredDownloads;
const handleItemClick = (e: React.MouseEvent, item: DownloadItem) => { const handleItemClick = (e: React.MouseEvent, item: DownloadItem) => {
if (e.metaKey || e.ctrlKey) { if (e.metaKey || e.ctrlKey) {
const newSelected = new Set(selectedIds); const newSelected = new Set(selectedIds);
@@ -193,8 +199,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
setSelectedIds(newSelected); setSelectedIds(newSelected);
setLastSelectedId(item.id); setLastSelectedId(item.id);
} else if (e.shiftKey && lastSelectedId) { } else if (e.shiftKey && lastSelectedId) {
const currentIndex = filteredDownloads.findIndex(d => d.id === item.id); const currentIndex = sortedDownloads.findIndex(d => d.id === item.id);
const lastIndex = filteredDownloads.findIndex(d => d.id === lastSelectedId); const lastIndex = sortedDownloads.findIndex(d => d.id === lastSelectedId);
if (currentIndex !== -1 && lastIndex !== -1) { if (currentIndex !== -1 && lastIndex !== -1) {
const start = Math.min(currentIndex, lastIndex); const start = Math.min(currentIndex, lastIndex);
@@ -202,7 +208,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const newSelected = new Set(selectedIds); const newSelected = new Set(selectedIds);
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
newSelected.add(filteredDownloads[i].id); newSelected.add(sortedDownloads[i].id);
} }
setSelectedIds(newSelected); setSelectedIds(newSelected);
} }
@@ -290,9 +296,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<button <button
className="main-control-button" className="main-control-button"
disabled={filteredDownloads.length === 0} disabled={sortedDownloads.length === 0}
onClick={() => { onClick={() => {
filteredDownloads sortedDownloads
.filter(d => canStartDownload(d.status)) .filter(d => canStartDownload(d.status))
.forEach(d => handleResume(d)); .forEach(d => handleResume(d));
}} }}
@@ -303,9 +309,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<button <button
className="main-control-button" className="main-control-button"
disabled={filteredDownloads.length === 0} disabled={sortedDownloads.length === 0}
onClick={() => { onClick={() => {
filteredDownloads.filter(d => canPauseDownload(d.status)).forEach(d => handlePause(d.id)); sortedDownloads.filter(d => canPauseDownload(d.status)).forEach(d => handlePause(d.id));
}} }}
title="Pause All" title="Pause All"
> >
@@ -317,12 +323,12 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<div className="downloads-content-header"> <div className="downloads-content-header">
<div className="downloads-title"> <div className="downloads-title">
{getFilterTitle()} {getFilterTitle()}
<span className="downloads-count">{filteredDownloads.length}</span> <span className="downloads-count">{sortedDownloads.length}</span>
</div> </div>
</div> </div>
<div className="downloads-table flex-1 flex flex-col"> <div className="downloads-table flex-1 flex flex-col">
{filteredDownloads.length === 0 ? ( {sortedDownloads.length === 0 ? (
<div className="downloads-empty-state"> <div className="downloads-empty-state">
<ArrowDownCircle aria-hidden="true" /> <ArrowDownCircle aria-hidden="true" />
<div className="downloads-empty-title">No Downloads</div> <div className="downloads-empty-title">No Downloads</div>
@@ -357,7 +363,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<div className="download-table-body"> <div className="download-table-body">
<div className="download-table-list"> <div className="download-table-list">
{filteredDownloads.map((d, index) => ( {sortedDownloads.map((d, index) => (
<DownloadItemComponent <DownloadItemComponent
key={d.id} key={d.id}
downloadId={d.id} downloadId={d.id}
+8
View File
@@ -45,3 +45,11 @@ if (rootElement) {
</StrictMode>, </StrictMode>,
); );
} }
// Prevent the webview's default context menu ("Reload", etc.) on right-click.
// Individual components that provide custom context menus call preventDefault()
// in their own onContextMenu handlers, which fires before this document-level
// listener and is unaffected.
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
+5 -3
View File
@@ -179,7 +179,7 @@ const generateSecureToken = () => {
export const useSettingsStore = create<SettingsState>()( export const useSettingsStore = create<SettingsState>()(
persist( persist(
(set, get) => ({ (set, _get) => ({
theme: 'system', theme: 'system',
baseDownloadFolder: '~/Downloads', baseDownloadFolder: '~/Downloads',
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS }, categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
@@ -316,8 +316,10 @@ export const useSettingsStore = create<SettingsState>()(
set({ extensionPairingToken: token }); set({ extensionPairingToken: token });
}, },
hydratePairingToken: async () => { hydratePairingToken: async () => {
const granted = get().keychainAccessGranted; // Always use the safe hydration path that never touches the OS keychain
const result = await invoke(granted ? 'grant_keychain_access' : 'hydrate_extension_pairing_token'); // on its own. The modal will be shown when needed and only the explicit
// "Grant Access" action (→ grant_keychain_access) triggers the OS prompt.
const result = await invoke('hydrate_extension_pairing_token');
set({ set({
extensionPairingToken: result.token, extensionPairingToken: result.token,
isPairingTokenPersistent: result.persistent isPairingTokenPersistent: result.persistent
+1 -1
View File
@@ -2,7 +2,7 @@ import { info as tauriInfo, warn as tauriWarn, error as tauriError, debug as tau
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
// Default to true to match backend default // Default to true to match backend default
let isPaused = false; let isPaused = true;
let initPromise: Promise<void> | null = null; let initPromise: Promise<void> | null = null;