diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index ff8981e..27ccf77 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -10,6 +10,12 @@ const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app"; const CURRENT_SCHEMA_VERSION: i64 = 1; pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed"; 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"; static KEYRING_OPERATION_LOCK: Mutex<()> = Mutex::new(()); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 18d86f1..6e128ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2936,6 +2936,7 @@ pub struct AppState { /// again before any stale or early IPC caller can reach the OS store. pub keychain_access_authorized: Arc, pub keychain_grant_in_progress: Arc, + keychain_grant: Arc>, pub extension_pairing_token: extension_server::SharedExtensionToken, pub extension_frontend_ready: extension_server::SharedFrontendReady, pub extension_acks: extension_server::SharedExtensionAcks, @@ -2961,6 +2962,15 @@ const KEYCHAIN_CONSENT_REQUIRED_ERROR: &str = "Credential-store access requires explicit user consent"; const KEYCHAIN_GRANT_IN_PROGRESS_ERROR: &str = "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> { if state @@ -2981,12 +2991,28 @@ impl Drop for KeychainGrantGuard { } } -fn begin_keychain_grant(state: &AppState) -> Result { - state +fn begin_keychain_grant( + state: &AppState, + request_id: &str, +) -> Result { + validate_keychain_grant_request_id(request_id)?; + let guard = state .keychain_grant_in_progress .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .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)] @@ -5880,7 +5906,7 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result, state: tauri::State<'_, AppState>, @@ -5898,7 +5924,7 @@ fn set_keychain_password( crate::db::set_keychain_password(&id, &password) } -#[tauri::command] +#[tauri::command(async)] fn get_keychain_password( state: tauri::State<'_, AppState>, id: String, @@ -5912,7 +5938,7 @@ fn get_keychain_password( crate::db::get_keychain_password(&id) } -#[tauri::command] +#[tauri::command(async)] fn delete_keychain_password( state: tauri::State<'_, AppState>, id: String, @@ -5926,7 +5952,7 @@ fn delete_keychain_password( crate::db::delete_keychain_password(&id) } -#[tauri::command] +#[tauri::command(async)] fn save_site_login( database: tauri::State<'_, crate::db::DbState>, state: tauri::State<'_, AppState>, @@ -5940,7 +5966,7 @@ fn save_site_login( crate::db::save_site_login(&connection, &id, &url_pattern, &username, &password) } -#[tauri::command] +#[tauri::command(async)] fn delete_site_login( database: tauri::State<'_, crate::db::DbState>, state: tauri::State<'_, AppState>, @@ -5965,7 +5991,7 @@ fn authorize_keychain_access(state: tauri::State<'_, AppState>) -> Result<(), St Ok(()) } -#[derive(Serialize, TS)] +#[derive(Clone, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../../src/bindings/")] struct PairingTokenHydration { @@ -5975,10 +6001,28 @@ struct PairingTokenHydration { error: Option, } +#[derive(Default)] +struct KeychainGrantState { + request_id: Option, + abandoned_request_ids: HashSet, + cancelled: bool, + committed: bool, + result: Option>, +} + +#[derive(Clone, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +struct KeychainGrantStatus { + started: bool, + in_progress: bool, + result: Option, +} + /// Hydrate the extension pairing token after the frontend is ready. Standard /// mode uses the OS credential store; portable mode intentionally keeps the /// token with the portable settings folder. -#[tauri::command] +#[tauri::command(async)] fn hydrate_extension_pairing_token( database: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, @@ -6050,7 +6094,7 @@ fn get_session_pairing_token( }) } -#[tauri::command] +#[tauri::command(async)] fn regenerate_pairing_token( database: tauri::State<'_, crate::db::DbState>, 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] -fn grant_keychain_access( - database: tauri::State<'_, crate::db::DbState>, +async fn grant_keychain_access( + app_handle: tauri::AppHandle, + request_id: String, +) -> Result<(), String> { + validate_keychain_grant_request_id(&request_id)?; + let app_state = app_handle.state::(); + let grant_guard = begin_keychain_grant(app_state.inner(), &request_id)?; + tauri::async_runtime::spawn_blocking(move || { + let database = app_handle.state::(); + let app_state = app_handle.state::(); + 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>, + request_id: String, +) -> Result { + 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 { - 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, 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 { + log::debug!("keychain grant worker started"); let connection = database.lock()?; + log::debug!("keychain grant database access acquired"); if app_state.storage_layout.is_portable() { 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)?; generated }; - 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); + log::debug!("keychain grant completed using portable storage"); return Ok(PairingTokenHydration { token, token_changed: false, @@ -6136,6 +6316,7 @@ fn grant_keychain_access( } if let Err(error) = crate::db::migrate_legacy_pairing_token(&connection) { + log::debug!("keychain grant legacy migration did not complete"); let token = app_state .extension_pairing_token .read() @@ -6172,15 +6353,7 @@ fn grant_keychain_access( generated } }; - - { - 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); + log::debug!("keychain grant credential operation completed"); Ok(PairingTokenHydration { token, token_changed: false, @@ -6690,11 +6863,19 @@ mod tests { aria2_active_connection_count, parse_media_playlist_metadata, 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 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] fn aria2_active_connection_count_uses_only_nonnegative_daemon_values() { assert_eq!( @@ -9205,6 +9386,7 @@ pub fn run() { storage_layout, keychain_access_authorized: 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_frontend_ready, extension_acks, @@ -9797,6 +9979,7 @@ pub fn run() { set_keychain_password, get_keychain_password, delete_keychain_password, save_site_login, delete_site_login, 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, acknowledge_pairing_token_change, check_file_exists, toggle_tray_icon, set_extension_pairing_token, diff --git a/src/App.tsx b/src/App.tsx index a8e72b3..a65014f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads'; 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 { DownloadTable, type DownloadTableStatusSummary } from "./components/DownloadTable"; // 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 { openUrl } from '@tauri-apps/plugin-opener'; import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform'; -import { resolveWindowControlStyle } from './utils/windowControlStyle'; +import { getWindowControlRevealOffset, resolveWindowControlStyle } from './utils/windowControlStyle'; import { getKeychainAccessReady, getKeychainConsentVersion, @@ -264,6 +264,7 @@ function App() { const isMacUserAgent = navigator.userAgent.includes('Mac'); const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent); const windowControlStyle = resolveWindowControlStyle(windowControlStylePreference, platform.os, navigator.userAgent); + const windowControlRevealOffset = getWindowControlRevealOffset(windowControlStyle); const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl'; const isSidebarOnRight = sidebarPosition === 'right' || (sidebarPosition === 'auto' && isRtl); // 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' } ${ hasWindowChrome ? 'app-shell--window-chrome' : '' - }`}> + }`} + style={{ '--window-control-reveal-offset': `${windowControlRevealOffset}px` } as CSSProperties} + > {usesCustomWindowControls && ( = ( const platform = usePlatformInfo(); const [isGranting, setIsGranting] = useState(false); const [grantRequestPending, setGrantRequestPending] = useState(false); - const [grantRequestTimedOut, setGrantRequestTimedOut] = useState(false); + const [grantRequestWaiting, setGrantRequestWaiting] = useState(false); const [error, setError] = useState(null); const isMountedRef = useRef(true); const grantRequestRef = useRef | null>(null); + const grantRequestIdRef = useRef(null); const grantAttemptRef = useRef(0); + const consentVersionRef = useRef(consentVersion); + consentVersionRef.current = consentVersion; const modalRef = useModalFocus(showKeychainModal); - useEffect(() => () => { - isMountedRef.current = false; - grantAttemptRef.current += 1; + const applyPersistentGrantToStore = (result: PairingTokenHydration) => { + if (!result.persistent) return; + 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(() => { @@ -37,12 +72,7 @@ export const KeychainPermissionModal: React.FC = ( const handleEscape = (event: KeyboardEvent) => { if (event.key !== 'Escape' || !isTopmostModal(modalRef.current)) return; event.preventDefault(); - grantAttemptRef.current += 1; - if (consentVersion.trim()) { - dismissKeychainPrompt(consentVersion); - } else { - useSettingsStore.getState().setShowKeychainModal(false); - } + void handleLater(); }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); @@ -81,8 +111,9 @@ export const KeychainPermissionModal: React.FC = ( // nothing while an earlier native request is still outstanding. if (grantRequestRef.current) return; const grantAttempt = ++grantAttemptRef.current; + const grantRequestId = crypto.randomUUID(); setIsGranting(true); - setGrantRequestTimedOut(false); + setGrantRequestWaiting(false); setError(null); let timeoutId: number | undefined; @@ -94,26 +125,64 @@ export const KeychainPermissionModal: React.FC = ( // that explicit choice into a silent authorization. if (!isMountedRef.current || grantAttemptRef.current !== grantAttempt) return false; persistentGrantApplied = true; - // App startup normally provides the version. Do not issue another IPC - // 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 - }); + applyPersistentGrantToStore(result); return true; }; let grantRequest: Promise; 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(resolve => { + window.setTimeout(resolve, KEYCHAIN_GRANT_STATUS_POLL_MS); + }); + } + })(); } catch (error) { if (isMountedRef.current) { setIsGranting(false); @@ -122,20 +191,27 @@ export const KeychainPermissionModal: React.FC = ( return; } grantRequestRef.current = grantRequest; + grantRequestIdRef.current = grantRequestId; setGrantRequestPending(true); void grantRequest.then( () => { - if (grantRequestRef.current === grantRequest) grantRequestRef.current = null; + if (grantRequestRef.current === grantRequest) { + grantRequestRef.current = null; + grantRequestIdRef.current = null; + } if (isMountedRef.current) { 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) { setGrantRequestPending(false); - setGrantRequestTimedOut(false); + setGrantRequestWaiting(false); } } ); @@ -150,7 +226,15 @@ export const KeychainPermissionModal: React.FC = ( new Promise((_, reject) => { 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))); }, KEYCHAIN_GRANT_TIMEOUT_MS @@ -170,8 +254,16 @@ export const KeychainPermissionModal: React.FC = ( } }; - const handleLater = () => { + const handleLater = async () => { + const requestId = grantRequestIdRef.current; grantAttemptRef.current += 1; + if (requestId) { + const accepted = await invoke('abandon_keychain_grant', { requestId }).catch(() => null); + if (accepted?.persistent) { + applyPersistentGrantToStore(accepted); + return; + } + } if (consentVersion.trim()) { dismissKeychainPrompt(consentVersion); } else { @@ -187,7 +279,7 @@ export const KeychainPermissionModal: React.FC = (
{ - if (event.target === event.currentTarget && isTopmostModal(modalRef.current)) handleLater(); + if (event.target === event.currentTarget && isTopmostModal(modalRef.current)) void handleLater(); }} role="dialog" aria-modal="true" @@ -259,7 +351,7 @@ export const KeychainPermissionModal: React.FC = ( > {isGranting ? t($ => $.keychain.enabling) - : grantRequestTimedOut && grantRequestPending + : grantRequestWaiting && grantRequestPending ? t($ => $.keychain.waitingForPrompt) : grantLabel} diff --git a/src/index.css b/src/index.css index 8cdd6a9..6d420bc 100644 --- a/src/index.css +++ b/src/index.css @@ -1411,23 +1411,26 @@ html[data-list-density="relaxed"] { .app-sidebar-reveal-button { position: absolute; top: 12px; - left: 88px; + left: var(--window-control-reveal-offset, 88px); right: auto; z-index: 80; -webkit-app-region: no-drag; } - .app-workspace--custom-window-controls .app-sidebar-reveal-button { - left: 124px; + /* Native-decorated windows do not render the custom control rail. Keep the + 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 { left: auto; - right: 88px; + right: var(--window-control-reveal-offset, 88px); } - .app-workspace--sidebar-right.app-workspace--custom-window-controls .app-sidebar-reveal-button { - right: 124px; + .app-workspace--sidebar-right:not(.app-workspace--custom-window-controls) .app-sidebar-reveal-button { + right: 88px; } .app-sidebar-shell--left .sidebar-nav-item { @@ -2110,7 +2113,7 @@ html[data-list-density="relaxed"] { 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); } @@ -2214,6 +2217,11 @@ html[data-list-density="relaxed"] { background: transparent; box-shadow: none; 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 { @@ -2225,17 +2233,23 @@ html[data-list-density="relaxed"] { .window-controls--style-gnome .window-control:hover, .window-controls--style-minimal .window-control:hover { 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; } -.window-controls--style-gnome .window-control.close, -.window-controls--style-gnome .window-control.minimize, -.window-controls--style-gnome .window-control.maximize, -.window-controls--style-minimal .window-control.close, -.window-controls--style-minimal .window-control.minimize, -.window-controls--style-minimal .window-control.maximize { - background: transparent; +.window-controls--style-gnome .window-control:active, +.window-controls--style-minimal .window-control:active { + background: hsl(var(--text-primary) / 0.17); + box-shadow: inset 0 0 0 1px hsl(var(--text-primary) / 0.08); + transform: none; +} + +.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. */ @@ -2271,21 +2285,22 @@ html[data-list-density="relaxed"] { } .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; } -.app-workspace--sidebar-collapsed.app-workspace--custom-window-controls .main-titlebar { - padding-left: 160px; +.app-workspace--sidebar-collapsed:not(.app-workspace--custom-window-controls) .main-titlebar { + padding-left: 124px; } .app-workspace--sidebar-right.app-workspace--sidebar-collapsed .main-titlebar { 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 { - padding-right: 160px; +.app-workspace--sidebar-right.app-workspace--sidebar-collapsed:not(.app-workspace--custom-window-controls) .main-titlebar { + padding-right: 124px; } .main-titlebar-title { diff --git a/src/ipc.ts b/src/ipc.ts index 5400e88..7d38e72 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -13,6 +13,7 @@ import type { EngineStatusItem } from './bindings/EngineStatusItem'; import type { PostQueueAction } from './bindings/PostQueueAction'; import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome'; import type { PairingTokenHydration } from './bindings/PairingTokenHydration'; +import type { KeychainGrantStatus } from './bindings/KeychainGrantStatus'; import type { EnqueueItem } from './bindings/EnqueueItem'; import type { EnqueueAccepted } from './bindings/EnqueueAccepted'; import type { PlatformInfo } from './bindings/PlatformInfo'; @@ -76,7 +77,10 @@ type CommandMap = { get_session_pairing_token: { args: undefined; result: PairingTokenHydration }; authorize_keychain_access: { args: undefined; result: void }; 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 }; set_extension_frontend_ready: { args: { ready: boolean }; result: void }; ack_extension_download: { args: { requestId: string }; result: void }; diff --git a/src/utils/windowControlStyle.test.ts b/src/utils/windowControlStyle.test.ts index ecb00be..657d7d1 100644 --- a/src/utils/windowControlStyle.test.ts +++ b/src/utils/windowControlStyle.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { resolveWindowControlStyle } from './windowControlStyle'; +import { getWindowControlRevealOffset, resolveWindowControlStyle } from './windowControlStyle'; describe('resolveWindowControlStyle', () => { it('uses the platform convention for automatic style', () => { @@ -25,4 +25,11 @@ describe('resolveWindowControlStyle', () => { expect(resolveWindowControlStyle('gnome', 'macos')).toBe('gnome'); 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); + }); }); diff --git a/src/utils/windowControlStyle.ts b/src/utils/windowControlStyle.ts index 1a00e85..83638bb 100644 --- a/src/utils/windowControlStyle.ts +++ b/src/utils/windowControlStyle.ts @@ -2,6 +2,19 @@ import type { WindowControlStyle } from '../bindings/WindowControlStyle'; export type ResolvedWindowControlStyle = Exclude; +// 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 = { + macos: 88, + windows: 168, + gnome: 134, + minimal: 104, +}; + +export const getWindowControlRevealOffset = (style: ResolvedWindowControlStyle): number => + WINDOW_CONTROL_REVEAL_OFFSETS[style]; + export const resolveWindowControlStyle = ( style: WindowControlStyle, os: string,