fix(ui): harden keychain grants and window controls

Keep native credential-store completion pending until the frontend accepts it, prevent stale grant races, and isolate blocking keyring work from the UI. Derive sidebar reveal spacing from each custom control style and restore neutral GNOME/minimal hover feedback.
This commit is contained in:
NimBold
2026-07-29 15:07:38 +03:30
parent 07f45959d0
commit 1044b6c619
9 changed files with 418 additions and 91 deletions
+6
View File
@@ -10,6 +10,12 @@ const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app";
const CURRENT_SCHEMA_VERSION: i64 = 1; const CURRENT_SCHEMA_VERSION: i64 = 1;
pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed"; pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token"; pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
// Development builds are a different executable identity from the packaged
// app. Keep their credentials separate so a debug binary cannot trigger an
// access prompt for, or reuse, the release app's Keychain item.
#[cfg(debug_assertions)]
const KEYCHAIN_SERVICE: &str = "com.firelink.app.dev";
#[cfg(not(debug_assertions))]
const KEYCHAIN_SERVICE: &str = "com.firelink.app"; const KEYCHAIN_SERVICE: &str = "com.firelink.app";
static KEYRING_OPERATION_LOCK: Mutex<()> = Mutex::new(()); static KEYRING_OPERATION_LOCK: Mutex<()> = Mutex::new(());
+213 -30
View File
@@ -2936,6 +2936,7 @@ pub struct AppState {
/// again before any stale or early IPC caller can reach the OS store. /// again before any stale or early IPC caller can reach the OS store.
pub keychain_access_authorized: Arc<AtomicBool>, pub keychain_access_authorized: Arc<AtomicBool>,
pub keychain_grant_in_progress: Arc<AtomicBool>, pub keychain_grant_in_progress: Arc<AtomicBool>,
keychain_grant: Arc<Mutex<KeychainGrantState>>,
pub extension_pairing_token: extension_server::SharedExtensionToken, pub extension_pairing_token: extension_server::SharedExtensionToken,
pub extension_frontend_ready: extension_server::SharedFrontendReady, pub extension_frontend_ready: extension_server::SharedFrontendReady,
pub extension_acks: extension_server::SharedExtensionAcks, pub extension_acks: extension_server::SharedExtensionAcks,
@@ -2961,6 +2962,15 @@ const KEYCHAIN_CONSENT_REQUIRED_ERROR: &str =
"Credential-store access requires explicit user consent"; "Credential-store access requires explicit user consent";
const KEYCHAIN_GRANT_IN_PROGRESS_ERROR: &str = const KEYCHAIN_GRANT_IN_PROGRESS_ERROR: &str =
"Credential-store access is already being requested"; "Credential-store access is already being requested";
const KEYCHAIN_GRANT_REQUEST_ID_MAX_LEN: usize = 128;
const MAX_ABANDONED_KEYCHAIN_REQUESTS: usize = 64;
fn validate_keychain_grant_request_id(request_id: &str) -> Result<(), String> {
if request_id.trim().is_empty() || request_id.len() > KEYCHAIN_GRANT_REQUEST_ID_MAX_LEN {
return Err("Invalid keychain grant request ID".to_string());
}
Ok(())
}
fn require_keychain_access(state: &AppState) -> Result<(), String> { fn require_keychain_access(state: &AppState) -> Result<(), String> {
if state if state
@@ -2981,12 +2991,28 @@ impl Drop for KeychainGrantGuard {
} }
} }
fn begin_keychain_grant(state: &AppState) -> Result<KeychainGrantGuard, String> { fn begin_keychain_grant(
state state: &AppState,
request_id: &str,
) -> Result<KeychainGrantGuard, String> {
validate_keychain_grant_request_id(request_id)?;
let guard = state
.keychain_grant_in_progress .keychain_grant_in_progress
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.map(|_| KeychainGrantGuard(Arc::clone(&state.keychain_grant_in_progress))) .map(|_| KeychainGrantGuard(Arc::clone(&state.keychain_grant_in_progress)))
.map_err(|_| KEYCHAIN_GRANT_IN_PROGRESS_ERROR.to_string()) .map_err(|_| KEYCHAIN_GRANT_IN_PROGRESS_ERROR.to_string())?;
let mut grant = state
.keychain_grant
.lock()
.map_err(|_| "Keychain grant state lock is unavailable".to_string())?;
if grant.abandoned_request_ids.remove(request_id) {
return Err("Keychain grant was dismissed before it started".to_string());
}
grant.request_id = Some(request_id.to_string());
grant.cancelled = false;
grant.committed = false;
grant.result = None;
Ok(guard)
} }
#[derive(Clone, Serialize, TS)] #[derive(Clone, Serialize, TS)]
@@ -5880,7 +5906,7 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result<String,
} }
} }
#[tauri::command] #[tauri::command(async)]
fn set_keychain_password( fn set_keychain_password(
database: tauri::State<'_, crate::db::DbState>, database: tauri::State<'_, crate::db::DbState>,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
@@ -5898,7 +5924,7 @@ fn set_keychain_password(
crate::db::set_keychain_password(&id, &password) crate::db::set_keychain_password(&id, &password)
} }
#[tauri::command] #[tauri::command(async)]
fn get_keychain_password( fn get_keychain_password(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
id: String, id: String,
@@ -5912,7 +5938,7 @@ fn get_keychain_password(
crate::db::get_keychain_password(&id) crate::db::get_keychain_password(&id)
} }
#[tauri::command] #[tauri::command(async)]
fn delete_keychain_password( fn delete_keychain_password(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
id: String, id: String,
@@ -5926,7 +5952,7 @@ fn delete_keychain_password(
crate::db::delete_keychain_password(&id) crate::db::delete_keychain_password(&id)
} }
#[tauri::command] #[tauri::command(async)]
fn save_site_login( fn save_site_login(
database: tauri::State<'_, crate::db::DbState>, database: tauri::State<'_, crate::db::DbState>,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
@@ -5940,7 +5966,7 @@ fn save_site_login(
crate::db::save_site_login(&connection, &id, &url_pattern, &username, &password) crate::db::save_site_login(&connection, &id, &url_pattern, &username, &password)
} }
#[tauri::command] #[tauri::command(async)]
fn delete_site_login( fn delete_site_login(
database: tauri::State<'_, crate::db::DbState>, database: tauri::State<'_, crate::db::DbState>,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
@@ -5965,7 +5991,7 @@ fn authorize_keychain_access(state: tauri::State<'_, AppState>) -> Result<(), St
Ok(()) Ok(())
} }
#[derive(Serialize, TS)] #[derive(Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")] #[ts(export, export_to = "../../src/bindings/")]
struct PairingTokenHydration { struct PairingTokenHydration {
@@ -5975,10 +6001,28 @@ struct PairingTokenHydration {
error: Option<String>, error: Option<String>,
} }
#[derive(Default)]
struct KeychainGrantState {
request_id: Option<String>,
abandoned_request_ids: HashSet<String>,
cancelled: bool,
committed: bool,
result: Option<Result<PairingTokenHydration, String>>,
}
#[derive(Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
struct KeychainGrantStatus {
started: bool,
in_progress: bool,
result: Option<PairingTokenHydration>,
}
/// Hydrate the extension pairing token after the frontend is ready. Standard /// Hydrate the extension pairing token after the frontend is ready. Standard
/// mode uses the OS credential store; portable mode intentionally keeps the /// mode uses the OS credential store; portable mode intentionally keeps the
/// token with the portable settings folder. /// token with the portable settings folder.
#[tauri::command] #[tauri::command(async)]
fn hydrate_extension_pairing_token( fn hydrate_extension_pairing_token(
database: tauri::State<'_, crate::db::DbState>, database: tauri::State<'_, crate::db::DbState>,
app_state: tauri::State<'_, AppState>, app_state: tauri::State<'_, AppState>,
@@ -6050,7 +6094,7 @@ fn get_session_pairing_token(
}) })
} }
#[tauri::command] #[tauri::command(async)]
fn regenerate_pairing_token( fn regenerate_pairing_token(
database: tauri::State<'_, crate::db::DbState>, database: tauri::State<'_, crate::db::DbState>,
app_state: tauri::State<'_, AppState>, app_state: tauri::State<'_, AppState>,
@@ -6104,13 +6148,154 @@ fn regenerate_pairing_token(
}) })
} }
// Keychain access can synchronously show an OS authentication prompt. Keep
// the IPC command genuinely asynchronous and isolate all blocking credential
// store work from both the WebView and Tauri's async worker threads. This is
// important on macOS, where the Security framework can block while presenting
// an authorization prompt, and on Linux/Windows where the native stores may
// also perform synchronous IPC to their desktop credential service.
#[tauri::command] #[tauri::command]
fn grant_keychain_access( async fn grant_keychain_access(
database: tauri::State<'_, crate::db::DbState>, app_handle: tauri::AppHandle,
request_id: String,
) -> Result<(), String> {
validate_keychain_grant_request_id(&request_id)?;
let app_state = app_handle.state::<AppState>();
let grant_guard = begin_keychain_grant(app_state.inner(), &request_id)?;
tauri::async_runtime::spawn_blocking(move || {
let database = app_handle.state::<crate::db::DbState>();
let app_state = app_handle.state::<AppState>();
let result = grant_keychain_access_blocking(
database.inner(),
app_state.inner(),
grant_guard,
);
let Ok(mut grant) = app_state.keychain_grant.lock() else {
log::warn!("keychain grant state lock is unavailable");
return;
};
if grant.request_id.as_deref() != Some(request_id.as_str()) || grant.cancelled {
log::debug!("keychain grant result discarded after dismissal");
return;
}
if let Err(error) = &result {
log::warn!("keychain grant worker failed: {error}");
}
grant.result = Some(result);
});
Ok(())
}
#[tauri::command]
fn get_keychain_grant_status(
app_state: tauri::State<'_, AppState>, app_state: tauri::State<'_, AppState>,
request_id: String,
) -> Result<KeychainGrantStatus, String> {
validate_keychain_grant_request_id(&request_id)?;
let grant = app_state
.keychain_grant
.lock()
.map_err(|_| "Keychain grant state lock is unavailable".to_string())?;
let started = grant.request_id.as_deref() == Some(request_id.as_str());
let result = if started {
grant.result.as_ref().map(|result| match result {
Ok(result) => result.clone(),
Err(error) => PairingTokenHydration {
token: String::new(),
token_changed: false,
persistent: false,
error: Some(error.clone()),
},
})
} else {
None
};
let in_progress = app_state
.keychain_grant_in_progress
.load(Ordering::Acquire);
Ok(KeychainGrantStatus {
started,
in_progress,
result,
})
}
#[tauri::command]
fn accept_keychain_grant(
app_state: tauri::State<'_, AppState>,
request_id: String,
) -> Result<PairingTokenHydration, String> { ) -> Result<PairingTokenHydration, String> {
let _grant_guard = begin_keychain_grant(&app_state)?; validate_keychain_grant_request_id(&request_id)?;
let mut grant = app_state
.keychain_grant
.lock()
.map_err(|_| "Keychain grant state lock is unavailable".to_string())?;
if grant.request_id.as_deref() != Some(request_id.as_str()) {
return Err("Keychain grant request is no longer active".to_string());
}
let result = grant
.result
.take()
.ok_or_else(|| "Keychain grant result is no longer available".to_string())??;
if !result.persistent || result.token.trim().is_empty() {
return Ok(result);
}
let mut pairing_token = app_state
.extension_pairing_token
.write()
.map_err(|_| "Extension pairing token lock is unavailable".to_string())?;
*pairing_token = result.token.clone();
app_state
.keychain_access_authorized
.store(true, Ordering::Release);
grant.committed = true;
log::debug!("keychain grant accepted; extension token updated");
Ok(result)
}
#[tauri::command]
fn abandon_keychain_grant(
app_state: tauri::State<'_, AppState>,
request_id: String,
) -> Result<Option<PairingTokenHydration>, String> {
validate_keychain_grant_request_id(&request_id)?;
let mut grant = app_state
.keychain_grant
.lock()
.map_err(|_| "Keychain grant state lock is unavailable".to_string())?;
if grant.request_id.as_deref() != Some(request_id.as_str()) {
if grant.abandoned_request_ids.len() >= MAX_ABANDONED_KEYCHAIN_REQUESTS {
return Err("Too many pending keychain grant requests".to_string());
}
grant.abandoned_request_ids.insert(request_id);
return Ok(None);
}
if grant.committed {
let token = app_state
.extension_pairing_token
.read()
.map_err(|_| "Extension pairing token lock is unavailable".to_string())?
.clone();
return Ok(Some(PairingTokenHydration {
token,
token_changed: false,
persistent: true,
error: None,
}));
}
grant.cancelled = true;
grant.result = None;
Ok(None)
}
fn grant_keychain_access_blocking(
database: &crate::db::DbState,
app_state: &AppState,
_grant_guard: KeychainGrantGuard,
) -> Result<PairingTokenHydration, String> {
log::debug!("keychain grant worker started");
let connection = database.lock()?; let connection = database.lock()?;
log::debug!("keychain grant database access acquired");
if app_state.storage_layout.is_portable() { if app_state.storage_layout.is_portable() {
let token = if let Some(existing) = crate::db::load_pairing_token_from_settings(&connection)? let token = if let Some(existing) = crate::db::load_pairing_token_from_settings(&connection)?
@@ -6121,12 +6306,7 @@ fn grant_keychain_access(
crate::db::save_pairing_token_to_settings(&connection, &generated, true)?; crate::db::save_pairing_token_to_settings(&connection, &generated, true)?;
generated generated
}; };
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { log::debug!("keychain grant completed using portable storage");
*pairing_token = token.clone();
}
app_state
.keychain_access_authorized
.store(true, Ordering::Release);
return Ok(PairingTokenHydration { return Ok(PairingTokenHydration {
token, token,
token_changed: false, token_changed: false,
@@ -6136,6 +6316,7 @@ fn grant_keychain_access(
} }
if let Err(error) = crate::db::migrate_legacy_pairing_token(&connection) { if let Err(error) = crate::db::migrate_legacy_pairing_token(&connection) {
log::debug!("keychain grant legacy migration did not complete");
let token = app_state let token = app_state
.extension_pairing_token .extension_pairing_token
.read() .read()
@@ -6172,15 +6353,7 @@ fn grant_keychain_access(
generated generated
} }
}; };
log::debug!("keychain grant credential operation completed");
{
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
*pairing_token = token.clone();
}
}
app_state
.keychain_access_authorized
.store(true, Ordering::Release);
Ok(PairingTokenHydration { Ok(PairingTokenHydration {
token, token,
token_changed: false, token_changed: false,
@@ -6690,11 +6863,19 @@ mod tests {
aria2_active_connection_count, aria2_active_connection_count,
parse_media_playlist_metadata, parse_media_playlist_metadata,
normalize_media_connections, normalize_media_connections,
validate_enqueue_url, validate_enqueue_uris, validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id,
}; };
use serde_json::json; use serde_json::json;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[test]
fn keychain_grant_request_ids_are_bounded_and_nonempty() {
assert!(validate_keychain_grant_request_id("grant-123").is_ok());
assert!(validate_keychain_grant_request_id("").is_err());
assert!(validate_keychain_grant_request_id(" ").is_err());
assert!(validate_keychain_grant_request_id(&"x".repeat(129)).is_err());
}
#[test] #[test]
fn aria2_active_connection_count_uses_only_nonnegative_daemon_values() { fn aria2_active_connection_count_uses_only_nonnegative_daemon_values() {
assert_eq!( assert_eq!(
@@ -9205,6 +9386,7 @@ pub fn run() {
storage_layout, storage_layout,
keychain_access_authorized: Arc::new(AtomicBool::new(false)), keychain_access_authorized: Arc::new(AtomicBool::new(false)),
keychain_grant_in_progress: Arc::new(AtomicBool::new(false)), keychain_grant_in_progress: Arc::new(AtomicBool::new(false)),
keychain_grant: Arc::new(Mutex::new(KeychainGrantState::default())),
extension_pairing_token, extension_pairing_token,
extension_frontend_ready, extension_frontend_ready,
extension_acks, extension_acks,
@@ -9797,6 +9979,7 @@ pub fn run() {
set_keychain_password, get_keychain_password, delete_keychain_password, set_keychain_password, get_keychain_password, delete_keychain_password,
save_site_login, delete_site_login, save_site_login, delete_site_login,
hydrate_extension_pairing_token, get_session_pairing_token, regenerate_pairing_token, grant_keychain_access, hydrate_extension_pairing_token, get_session_pairing_token, regenerate_pairing_token, grant_keychain_access,
get_keychain_grant_status, accept_keychain_grant, abandon_keychain_grant,
authorize_keychain_access, authorize_keychain_access,
acknowledge_pairing_token_change, acknowledge_pairing_token_change,
check_file_exists, toggle_tray_icon, set_extension_pairing_token, check_file_exists, toggle_tray_icon, set_extension_pairing_token,
+6 -3
View File
@@ -1,6 +1,6 @@
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads'; import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads';
import { schedulerCompletionState } from './utils/schedulerCompletion'; import { schedulerCompletionState } from './utils/schedulerCompletion';
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { Sidebar, SidebarFilter } from "./components/Sidebar"; import { Sidebar, SidebarFilter } from "./components/Sidebar";
import { DownloadTable, type DownloadTableStatusSummary } from "./components/DownloadTable"; import { DownloadTable, type DownloadTableStatusSummary } from "./components/DownloadTable";
// Keep the primary Add action eager so the modal cannot disappear behind a // Keep the primary Add action eager so the modal cannot disappear behind a
@@ -20,7 +20,7 @@ import { setLogStreamActive } from './utils/logger';
import { updateDockBadge } from './utils/dockBadge'; import { updateDockBadge } from './utils/dockBadge';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '@tauri-apps/plugin-opener';
import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform'; import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform';
import { resolveWindowControlStyle } from './utils/windowControlStyle'; import { getWindowControlRevealOffset, resolveWindowControlStyle } from './utils/windowControlStyle';
import { import {
getKeychainAccessReady, getKeychainAccessReady,
getKeychainConsentVersion, getKeychainConsentVersion,
@@ -264,6 +264,7 @@ function App() {
const isMacUserAgent = navigator.userAgent.includes('Mac'); const isMacUserAgent = navigator.userAgent.includes('Mac');
const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent); const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent);
const windowControlStyle = resolveWindowControlStyle(windowControlStylePreference, platform.os, navigator.userAgent); const windowControlStyle = resolveWindowControlStyle(windowControlStylePreference, platform.os, navigator.userAgent);
const windowControlRevealOffset = getWindowControlRevealOffset(windowControlStyle);
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl'; const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
const isSidebarOnRight = sidebarPosition === 'right' || (sidebarPosition === 'auto' && isRtl); const isSidebarOnRight = sidebarPosition === 'right' || (sidebarPosition === 'auto' && isRtl);
// Keep dialogs out of the titlebar area while platform detection is still // Keep dialogs out of the titlebar area while platform detection is still
@@ -1062,7 +1063,9 @@ function App() {
isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left' isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left'
} ${ } ${
hasWindowChrome ? 'app-shell--window-chrome' : '' hasWindowChrome ? 'app-shell--window-chrome' : ''
}`}> }`}
style={{ '--window-control-reveal-offset': `${windowControlRevealOffset}px` } as CSSProperties}
>
{usesCustomWindowControls && ( {usesCustomWindowControls && (
<WindowControls <WindowControls
side={isSidebarOnRight ? 'right' : 'left'} side={isSidebarOnRight ? 'right' : 'left'}
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PairingTokenHydration } from "./PairingTokenHydration";
export type KeychainGrantStatus = { started: boolean, inProgress: boolean, result: PairingTokenHydration | null, };
+127 -35
View File
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus'; import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000; const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
const KEYCHAIN_GRANT_STATUS_POLL_MS = 100;
type KeychainPermissionModalProps = { type KeychainPermissionModalProps = {
consentVersion: string; consentVersion: string;
@@ -20,16 +21,50 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
const platform = usePlatformInfo(); const platform = usePlatformInfo();
const [isGranting, setIsGranting] = useState(false); const [isGranting, setIsGranting] = useState(false);
const [grantRequestPending, setGrantRequestPending] = useState(false); const [grantRequestPending, setGrantRequestPending] = useState(false);
const [grantRequestTimedOut, setGrantRequestTimedOut] = useState(false); const [grantRequestWaiting, setGrantRequestWaiting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const grantRequestRef = useRef<Promise<PairingTokenHydration> | null>(null); const grantRequestRef = useRef<Promise<PairingTokenHydration> | null>(null);
const grantRequestIdRef = useRef<string | null>(null);
const grantAttemptRef = useRef(0); const grantAttemptRef = useRef(0);
const consentVersionRef = useRef(consentVersion);
consentVersionRef.current = consentVersion;
const modalRef = useModalFocus(showKeychainModal); const modalRef = useModalFocus(showKeychainModal);
useEffect(() => () => { const applyPersistentGrantToStore = (result: PairingTokenHydration) => {
isMountedRef.current = false; if (!result.persistent) return;
grantAttemptRef.current += 1; const grantedVersion = consentVersionRef.current.trim() || useSettingsStore.getState().keychainAccessVersion;
useSettingsStore.setState({
keychainAccessGranted: true,
keychainAccessVersion: grantedVersion,
keychainAccessReady: true,
extensionPairingToken: result.token,
isPairingTokenPersistent: true,
keychainPromptDismissed: false,
showKeychainModal: false
});
};
useEffect(() => {
// React Strict Mode replays effects in development. Reset the guard in
// setup so the replay cleanup cannot leave a live modal permanently
// unable to receive the native grant completion.
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
grantAttemptRef.current += 1;
const requestId = grantRequestIdRef.current;
if (requestId) {
void invoke('abandon_keychain_grant', { requestId })
.then(result => {
// If native acceptance won the same-lock race during unmount,
// keep the global store aligned even though this component is
// no longer mounted.
if (result?.persistent) applyPersistentGrantToStore(result);
})
.catch(() => undefined);
}
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -37,12 +72,7 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
const handleEscape = (event: KeyboardEvent) => { const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !isTopmostModal(modalRef.current)) return; if (event.key !== 'Escape' || !isTopmostModal(modalRef.current)) return;
event.preventDefault(); event.preventDefault();
grantAttemptRef.current += 1; void handleLater();
if (consentVersion.trim()) {
dismissKeychainPrompt(consentVersion);
} else {
useSettingsStore.getState().setShowKeychainModal(false);
}
}; };
window.addEventListener('keydown', handleEscape); window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape);
@@ -81,8 +111,9 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
// nothing while an earlier native request is still outstanding. // nothing while an earlier native request is still outstanding.
if (grantRequestRef.current) return; if (grantRequestRef.current) return;
const grantAttempt = ++grantAttemptRef.current; const grantAttempt = ++grantAttemptRef.current;
const grantRequestId = crypto.randomUUID();
setIsGranting(true); setIsGranting(true);
setGrantRequestTimedOut(false); setGrantRequestWaiting(false);
setError(null); setError(null);
let timeoutId: number | undefined; let timeoutId: number | undefined;
@@ -94,26 +125,64 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
// that explicit choice into a silent authorization. // that explicit choice into a silent authorization.
if (!isMountedRef.current || grantAttemptRef.current !== grantAttempt) return false; if (!isMountedRef.current || grantAttemptRef.current !== grantAttempt) return false;
persistentGrantApplied = true; persistentGrantApplied = true;
// App startup normally provides the version. Do not issue another IPC applyPersistentGrantToStore(result);
// request here when it is temporarily unknown: a successful grant must
// finish the modal even if version lookup is unavailable.
const grantedVersion = consentVersion.trim() || useSettingsStore.getState().keychainAccessVersion;
// Keep state in sync with the grant result instead of rehydrating
// before Zustand has persisted keychainAccessGranted.
useSettingsStore.setState({
keychainAccessGranted: true,
keychainAccessVersion: grantedVersion,
keychainAccessReady: true,
extensionPairingToken: result.token,
isPairingTokenPersistent: true,
keychainPromptDismissed: false,
showKeychainModal: false
});
return true; return true;
}; };
let grantRequest: Promise<PairingTokenHydration>; let grantRequest: Promise<PairingTokenHydration>;
try { try {
grantRequest = invoke('grant_keychain_access'); grantRequest = (async () => {
// Do not await this IPC request. The native command starts a worker
// and the worker can complete while the original WebView response
// remains unsettled around the OS credential dialog. Completion is
// observed only through the status command below.
let grantStartError: unknown = null;
let grantStartSettled = false;
const grantStart = invoke('grant_keychain_access', { requestId: grantRequestId });
void grantStart.then(
() => {
grantStartSettled = true;
},
(error: unknown) => {
grantStartError = error;
grantStartSettled = true;
},
);
if (isMountedRef.current) {
// The native request has been launched. Do not keep presenting the
// button as if the click itself were still running; the request
// remains pending until the worker reports completion.
setIsGranting(false);
setGrantRequestWaiting(true);
}
while (true) {
if (grantStartError) throw grantStartError;
const status = await invoke('get_keychain_grant_status', { requestId: grantRequestId });
const result = status.result;
if (result?.persistent) {
if (!isMountedRef.current || grantAttemptRef.current !== grantAttempt) {
return (await invoke('abandon_keychain_grant', { requestId: grantRequestId }).catch(() => null)) || {
token: '',
tokenChanged: false,
persistent: false,
error: null
};
}
return await invoke('accept_keychain_grant', { requestId: grantRequestId });
}
if (result?.error) return result;
if (grantStartSettled && !status.started && !status.inProgress) {
return {
token: '',
tokenChanged: false,
persistent: false,
error: t($ => $.keychain.unavailable, { store: siteCredentialStoreName })
};
}
await new Promise<void>(resolve => {
window.setTimeout(resolve, KEYCHAIN_GRANT_STATUS_POLL_MS);
});
}
})();
} catch (error) { } catch (error) {
if (isMountedRef.current) { if (isMountedRef.current) {
setIsGranting(false); setIsGranting(false);
@@ -122,20 +191,27 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
return; return;
} }
grantRequestRef.current = grantRequest; grantRequestRef.current = grantRequest;
grantRequestIdRef.current = grantRequestId;
setGrantRequestPending(true); setGrantRequestPending(true);
void grantRequest.then( void grantRequest.then(
() => { () => {
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null; if (grantRequestRef.current === grantRequest) {
grantRequestRef.current = null;
grantRequestIdRef.current = null;
}
if (isMountedRef.current) { if (isMountedRef.current) {
setGrantRequestPending(false); setGrantRequestPending(false);
setGrantRequestTimedOut(false); setGrantRequestWaiting(false);
} }
}, },
() => { () => {
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null; if (grantRequestRef.current === grantRequest) {
grantRequestRef.current = null;
grantRequestIdRef.current = null;
}
if (isMountedRef.current) { if (isMountedRef.current) {
setGrantRequestPending(false); setGrantRequestPending(false);
setGrantRequestTimedOut(false); setGrantRequestWaiting(false);
} }
} }
); );
@@ -150,7 +226,15 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
new Promise<never>((_, reject) => { new Promise<never>((_, reject) => {
timeoutId = window.setTimeout( timeoutId = window.setTimeout(
() => { () => {
if (isMountedRef.current) setGrantRequestTimedOut(true); if (isMountedRef.current) {
// The native request remains in flight and cannot be
// cancelled, but the webview must stop presenting the
// operation as an endlessly active click. Keep the request
// identity so a second OS prompt cannot be started, while
// allowing the user to dismiss this explanation.
setGrantRequestWaiting(true);
setIsGranting(false);
}
reject(new Error(t($ => $.keychain.timeout))); reject(new Error(t($ => $.keychain.timeout)));
}, },
KEYCHAIN_GRANT_TIMEOUT_MS KEYCHAIN_GRANT_TIMEOUT_MS
@@ -170,8 +254,16 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
} }
}; };
const handleLater = () => { const handleLater = async () => {
const requestId = grantRequestIdRef.current;
grantAttemptRef.current += 1; grantAttemptRef.current += 1;
if (requestId) {
const accepted = await invoke('abandon_keychain_grant', { requestId }).catch(() => null);
if (accepted?.persistent) {
applyPersistentGrantToStore(accepted);
return;
}
}
if (consentVersion.trim()) { if (consentVersion.trim()) {
dismissKeychainPrompt(consentVersion); dismissKeychainPrompt(consentVersion);
} else { } else {
@@ -187,7 +279,7 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
<div <div
className="app-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center" className="app-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center"
onClick={(event) => { onClick={(event) => {
if (event.target === event.currentTarget && isTopmostModal(modalRef.current)) handleLater(); if (event.target === event.currentTarget && isTopmostModal(modalRef.current)) void handleLater();
}} }}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -259,7 +351,7 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
> >
{isGranting {isGranting
? t($ => $.keychain.enabling) ? t($ => $.keychain.enabling)
: grantRequestTimedOut && grantRequestPending : grantRequestWaiting && grantRequestPending
? t($ => $.keychain.waitingForPrompt) ? t($ => $.keychain.waitingForPrompt)
: grantLabel} : grantLabel}
</button> </button>
+36 -21
View File
@@ -1411,23 +1411,26 @@ html[data-list-density="relaxed"] {
.app-sidebar-reveal-button { .app-sidebar-reveal-button {
position: absolute; position: absolute;
top: 12px; top: 12px;
left: 88px; left: var(--window-control-reveal-offset, 88px);
right: auto; right: auto;
z-index: 80; z-index: 80;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.app-workspace--custom-window-controls .app-sidebar-reveal-button { /* Native-decorated windows do not render the custom control rail. Keep the
left: 124px; original compact reveal position instead of reserving custom-control
space for an invisible control group. */
.app-workspace:not(.app-workspace--custom-window-controls) .app-sidebar-reveal-button {
left: 88px;
} }
.app-workspace--sidebar-right .app-sidebar-reveal-button { .app-workspace--sidebar-right .app-sidebar-reveal-button {
left: auto; left: auto;
right: 88px; right: var(--window-control-reveal-offset, 88px);
} }
.app-workspace--sidebar-right.app-workspace--custom-window-controls .app-sidebar-reveal-button { .app-workspace--sidebar-right:not(.app-workspace--custom-window-controls) .app-sidebar-reveal-button {
right: 124px; right: 88px;
} }
.app-sidebar-shell--left .sidebar-nav-item { .app-sidebar-shell--left .sidebar-nav-item {
@@ -2110,7 +2113,7 @@ html[data-list-density="relaxed"] {
filter: saturate(1.14) brightness(1.05); filter: saturate(1.14) brightness(1.05);
} }
.window-controls:hover .window-control { .window-controls--style-macos:hover .window-control {
color: hsl(0 0% 8% / 0.68); color: hsl(0 0% 8% / 0.68);
} }
@@ -2214,6 +2217,11 @@ html[data-list-density="relaxed"] {
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
color: hsl(var(--text-primary) / 0.84); color: hsl(var(--text-primary) / 0.84);
transition:
background-color 120ms ease,
box-shadow 120ms ease,
color 120ms ease,
transform 120ms ease;
} }
.window-controls--style-gnome .window-control { .window-controls--style-gnome .window-control {
@@ -2225,17 +2233,23 @@ html[data-list-density="relaxed"] {
.window-controls--style-gnome .window-control:hover, .window-controls--style-gnome .window-control:hover,
.window-controls--style-minimal .window-control:hover { .window-controls--style-minimal .window-control:hover {
color: hsl(var(--text-primary)); color: hsl(var(--text-primary));
background: hsl(var(--item-hover)); /* GNOME's flat header-bar buttons surface a neutral background on hover. */
background: hsl(var(--text-primary) / 0.11);
box-shadow: inset 0 0 0 1px hsl(var(--text-primary) / 0.05);
filter: none; filter: none;
} }
.window-controls--style-gnome .window-control.close, .window-controls--style-gnome .window-control:active,
.window-controls--style-gnome .window-control.minimize, .window-controls--style-minimal .window-control:active {
.window-controls--style-gnome .window-control.maximize, background: hsl(var(--text-primary) / 0.17);
.window-controls--style-minimal .window-control.close, box-shadow: inset 0 0 0 1px hsl(var(--text-primary) / 0.08);
.window-controls--style-minimal .window-control.minimize, transform: none;
.window-controls--style-minimal .window-control.maximize { }
background: transparent;
.window-controls--style-gnome .window-control:focus-visible,
.window-controls--style-minimal .window-control:focus-visible {
outline: 2px solid hsl(var(--accent-color) / 0.72);
outline-offset: 2px;
} }
/* Minimal keeps the compact footprint while removing colored circles. */ /* Minimal keeps the compact footprint while removing colored circles. */
@@ -2271,21 +2285,22 @@ html[data-list-density="relaxed"] {
} }
.app-workspace--sidebar-collapsed .main-titlebar { .app-workspace--sidebar-collapsed .main-titlebar {
padding-left: 124px; /* Keep the title after the reveal button and its control-rail gap. */
padding-left: calc(var(--window-control-reveal-offset, 88px) + 36px);
padding-right: 18px; padding-right: 18px;
} }
.app-workspace--sidebar-collapsed.app-workspace--custom-window-controls .main-titlebar { .app-workspace--sidebar-collapsed:not(.app-workspace--custom-window-controls) .main-titlebar {
padding-left: 160px; padding-left: 124px;
} }
.app-workspace--sidebar-right.app-workspace--sidebar-collapsed .main-titlebar { .app-workspace--sidebar-right.app-workspace--sidebar-collapsed .main-titlebar {
padding-left: 18px; padding-left: 18px;
padding-right: 124px; padding-right: calc(var(--window-control-reveal-offset, 88px) + 36px);
} }
.app-workspace--sidebar-right.app-workspace--sidebar-collapsed.app-workspace--custom-window-controls .main-titlebar { .app-workspace--sidebar-right.app-workspace--sidebar-collapsed:not(.app-workspace--custom-window-controls) .main-titlebar {
padding-right: 160px; padding-right: 124px;
} }
.main-titlebar-title { .main-titlebar-title {
+5 -1
View File
@@ -13,6 +13,7 @@ import type { EngineStatusItem } from './bindings/EngineStatusItem';
import type { PostQueueAction } from './bindings/PostQueueAction'; import type { PostQueueAction } from './bindings/PostQueueAction';
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome'; import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
import type { PairingTokenHydration } from './bindings/PairingTokenHydration'; import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
import type { KeychainGrantStatus } from './bindings/KeychainGrantStatus';
import type { EnqueueItem } from './bindings/EnqueueItem'; import type { EnqueueItem } from './bindings/EnqueueItem';
import type { EnqueueAccepted } from './bindings/EnqueueAccepted'; import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
import type { PlatformInfo } from './bindings/PlatformInfo'; import type { PlatformInfo } from './bindings/PlatformInfo';
@@ -76,7 +77,10 @@ type CommandMap = {
get_session_pairing_token: { args: undefined; result: PairingTokenHydration }; get_session_pairing_token: { args: undefined; result: PairingTokenHydration };
authorize_keychain_access: { args: undefined; result: void }; authorize_keychain_access: { args: undefined; result: void };
regenerate_pairing_token: { args: undefined; result: PairingTokenHydration }; regenerate_pairing_token: { args: undefined; result: PairingTokenHydration };
grant_keychain_access: { args: undefined; result: PairingTokenHydration }; grant_keychain_access: { args: { requestId: string }; result: void };
get_keychain_grant_status: { args: { requestId: string }; result: KeychainGrantStatus };
accept_keychain_grant: { args: { requestId: string }; result: PairingTokenHydration };
abandon_keychain_grant: { args: { requestId: string }; result: PairingTokenHydration | null };
acknowledge_pairing_token_change: { args: undefined; result: void }; acknowledge_pairing_token_change: { args: undefined; result: void };
set_extension_frontend_ready: { args: { ready: boolean }; result: void }; set_extension_frontend_ready: { args: { ready: boolean }; result: void };
ack_extension_download: { args: { requestId: string }; result: void }; ack_extension_download: { args: { requestId: string }; result: void };
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveWindowControlStyle } from './windowControlStyle'; import { getWindowControlRevealOffset, resolveWindowControlStyle } from './windowControlStyle';
describe('resolveWindowControlStyle', () => { describe('resolveWindowControlStyle', () => {
it('uses the platform convention for automatic style', () => { it('uses the platform convention for automatic style', () => {
@@ -25,4 +25,11 @@ describe('resolveWindowControlStyle', () => {
expect(resolveWindowControlStyle('gnome', 'macos')).toBe('gnome'); expect(resolveWindowControlStyle('gnome', 'macos')).toBe('gnome');
expect(resolveWindowControlStyle('minimal', 'windows')).toBe('minimal'); expect(resolveWindowControlStyle('minimal', 'windows')).toBe('minimal');
}); });
it('reserves space after the complete custom-control footprint', () => {
expect(getWindowControlRevealOffset('macos')).toBe(88);
expect(getWindowControlRevealOffset('windows')).toBe(168);
expect(getWindowControlRevealOffset('gnome')).toBe(134);
expect(getWindowControlRevealOffset('minimal')).toBe(104);
});
}); });
+13
View File
@@ -2,6 +2,19 @@ import type { WindowControlStyle } from '../bindings/WindowControlStyle';
export type ResolvedWindowControlStyle = Exclude<WindowControlStyle, 'auto'>; export type ResolvedWindowControlStyle = Exclude<WindowControlStyle, 'auto'>;
// The reveal button sits after the complete custom-control hit area. Keep this
// derived from the resolved style so a sidebar toggle can never overlap a
// platform-specific control footprint.
const WINDOW_CONTROL_REVEAL_OFFSETS: Record<ResolvedWindowControlStyle, number> = {
macos: 88,
windows: 168,
gnome: 134,
minimal: 104,
};
export const getWindowControlRevealOffset = (style: ResolvedWindowControlStyle): number =>
WINDOW_CONTROL_REVEAL_OFFSETS[style];
export const resolveWindowControlStyle = ( export const resolveWindowControlStyle = (
style: WindowControlStyle, style: WindowControlStyle,
os: string, os: string,